class PicketFence:
    """A class used for analyzing EPID images where radiation strips have been formed by the
    MLCs. The strips are assumed to be parallel to one another and normal to the image edge;
    i.e. a "left-right" or "up-down" orientation is assumed. Further work could follow up by accounting
    for any angle.
    """

    def __init__(self, filename: Union[str, Path, BinaryIO], filter: Optional[int] = None, log: Optional[str] = None,
                 use_filename: bool = False, mlc: Union[MLC, MLCArrangement, str] = MLC.MILLENNIUM, crop_mm: int = 3):
        """
        Parameters
        ----------
        filename
            Name of the file as a string or a file-like object.
        filter
            If None (default), no filtering will be done to the image.
            If an int, will perform median filtering over image of size ``filter``.
        log
            Path to a log file corresponding to the delivery. The expected fluence of the log file is
            used to construct the pickets. MLC peaks are then compared to an absolute reference instead of
            a fitted picket.
        use_filename
            If False (default), no action will be performed.
            If True, the filename will be searched for keywords that describe the gantry and/or collimator angle.
            For example, if set to True and the file name was "PF_gantry45.dcm" the gantry would be interpreted as being at 45 degrees.
        mlc
            The MLC model of the image. Must be an option from the enum :class:`~pylinac.picketfence.MLCs` or
            an :class:`~pylinac.picketfence.MLCArrangement`.
        crop_mm
            The number of mm to crop from all edges. Elekta is infamous for having columns of dead pixels on the side of their images.
            These need to be cleaned up first. For Varian images, this really shouldn't make a difference unless the pickets are
            very close to the edge. Generally speaking, they shouldn't be for the best accuracy.
        """
        leaf_analysis_width: float  #:
        mlc_meas: List  #:
        pickets: List  #:
        tolerance: float  #:
        action_tolerance: float  #:
        image: PFDicomImage  #:

        if filename is not None:
            self.image = PFDicomImage(filename, use_filenames=use_filename, crop_mm=crop_mm)
            if isinstance(filter, int):
                self.image.filter(size=filter)
            self.image.ground()
            self.image.normalize()
        if log is not None:
            self._load_log(log)
        else:
            self._log_fits = None
        self._is_analyzed = False
        self.mlc = self._get_mlc_arrangement(mlc)

    @staticmethod
    def _get_mlc_arrangement(value: Union[MLC, MLCArrangement, str]) -> MLCArrangement:
        if isinstance(value, MLC):
            return value.value['arrangement']
        if isinstance(value, MLCArrangement):
            return value
        if isinstance(value, str):
            return [member.value['arrangement'] for name, member in MLC.__members__.items() if member.value['name'] == value][0]

    @classmethod
    def from_url(cls, url: str, filter: int = None):
        """Instantiate from a URL."""
        filename = get_url(url, progress_bar=True)
        return cls(filename, filter=filter)

    @classmethod
    def from_demo_image(cls, filter: int = None):
        """Construct a PicketFence instance using the demo image."""
        demo_file = retrieve_demo_file(url='AS1200.dcm')
        return cls(demo_file, filter=filter)

    @classmethod
    def from_multiple_images(cls, path_list: Iterable[Union[str, Path]], dtype: np.dtype = np.uint16, **kwargs):
        """Load and superimpose multiple images and instantiate a Starshot object.

        Parameters
        ----------
        path_list : iterable
            An iterable of path locations to the files to be loaded/combined.
        kwargs
            Passed to :func:`~pylinac.core.image.load_multiples`.
        """
        obj = cls.from_demo_image()
        # save a combined image to a temporary dir, then load it back in as a PFDicomImage
        with TemporaryDirectory() as tmp:
            filename = osp.join(tmp, 'mydcm.dcm')
            image.load_multiples(path_list, dtype=dtype, **kwargs).save(filename)
            obj.image = PFDicomImage(filename)
        return obj

    @property
    def passed(self) -> bool:
        """Boolean specifying if all MLC positions were within tolerance."""
        # nested all because each measurement returns a list of booleans. So all the passes of each measurement must all pass.
        return all(all(m.passed) for m in self.mlc_meas)

    @property
    def percent_passing(self) -> float:
        """Return the percentage of MLC positions under tolerance."""
        num_meas = Enumerable(self.mlc_meas).select_many(lambda m: m.passed).count()
        num_pass = Enumerable(self.mlc_meas).select_many(lambda m: m.passed).count(lambda p: p == True)
        return float(100 * num_pass / num_meas)

    @property
    def max_error(self) -> float:
        """Return the maximum error found."""
        return float(np.max(np.abs(self._flattened_errors())))

    @property
    def max_error_picket(self) -> int:
        """Return the picket number where the maximum error occurred."""
        return Enumerable(self.mlc_meas)\
            .order_by_descending(lambda m: np.max(np.abs(m.error)))\
            .select(lambda m: m.picket_num)\
            .first()

    @property
    def max_error_leaf(self) -> int:
        """Return the leaf that had the maximum error."""
        if not self.separate_leaves:
            return Enumerable(self.mlc_meas)\
                .order_by_descending(lambda m: np.max(np.abs(m.error)))\
                .select(lambda m: m.full_leaf_nums)\
                .first()
        else:
            max_meas = Enumerable(self.mlc_meas) \
                .order_by_descending(lambda m: np.max(np.abs(m.error)))\
                .first()
            if abs(max_meas.error[0]) > abs(max_meas.error[1]):
                return max_meas.full_leaf_nums[0]
            else:
                return max_meas.full_leaf_nums[1]

    def _flattened_errors(self) -> List[float]:
        return Enumerable(self.mlc_meas).select_many(lambda m: m.error).to_list()

    def failed_leaves(self) -> Union[List[int], List[str]]:
        """A list of the failed leaves. Either the leaf number or the bank+leaf number if using separate leaves."""
        if not self._is_analyzed:
            raise ValueError("It appears the PF image has not been analyzed yet. Use .analyze() first.")
        failing_sets = Enumerable(self.mlc_meas).where(lambda m: not all(m.passed))
        if not self.separate_leaves:
            return failing_sets.select(lambda m: m.leaf_num)\
            .distinct()\
            .to_list()
        else:
            return failing_sets.select_many(lambda m: [m.full_leaf_nums[idx] for idx, passed in enumerate(m.passed) if not passed]).distinct().to_list()

    @property
    def abs_median_error(self) -> float:
        """Return the median error found."""
        return float(np.median(np.abs(self._flattened_errors())))

    @property
    def num_pickets(self) -> int:
        """Return the number of pickets determined."""
        return len(self.pickets)

    @property
    def mean_picket_spacing(self) -> float:
        """The average distance between pickets in mm."""
        sorted_pickets = sorted(self.pickets, key=lambda x: x.dist2cax)
        return float(np.mean([abs(sorted_pickets[idx].dist2cax - sorted_pickets[idx + 1].dist2cax) for idx in
                        range(len(sorted_pickets) - 1)]))

    def plot_leaf_profile(self, leaf: Union[str, int], picket: int, show: bool = True):
        """Plot the leaf profile of a given leaf pair parallel to leaf motion.

        Parameters
        ----------
        leaf
            The leaf to plot. If ``separate_leaves`` is True, this will be a string like "A15" or "B33".
            If ``separate_leaves`` is False, this must be an int, like ``15`` or ``33``.
        picket
            An int of the picket number. Pickets start from the 0-side of an image. E.g. for left-right PFs, this would start on the left; for up-down this would start at the bottom.
        """
        mlc_meas = Enumerable(self.mlc_meas).single(lambda m: leaf in m.full_leaf_nums and m.picket_num == picket)
        ax = mlc_meas.plot_detailed_profile()
        ax.set_title(f"MLC profile Leaf: {leaf}, Picket: {picket}")
        for lg, rg, m in zip(self.pickets[picket].left_guard_separated, self.pickets[picket].right_guard_separated, mlc_meas.marker_lines):
            g_val = lg(m.point1.y)
            rg_val = rg(m.point1.y)
            ax.axvline(g_val, color='green', label="Guard rail")
            ax.axvline(rg_val, color='green', label="Guard rail")
        ax.legend()
        if show:
            plt.show()

    def save_leaf_profile(self, filename: Union[str, Path, BinaryIO], leaf: Union[str, int], picket: int, **kwargs):
        """Save the leaf profile plot to disk or stream. See plot_leaf_profile for parameter hints. Kwargs are passed to matplotlib.savefig()"""
        self.plot_leaf_profile(leaf, picket, show=False)
        plt.savefig(filename, **kwargs)
        if not isinstance(filename, BytesIO):
            print(f"Picket fence leaf profile saved to: {osp.abspath(filename)}")

    def _load_log(self, log: str) -> None:
        """Load a machine log that corresponds to the picket fence delivery.

        This log determines the location of the pickets. The MLC peaks are then compared to the expected log pickets,
        not a simple fit of the peaks."""
        # load the log fluence image
        mlog = load_log(log)
        fl = mlog.fluence.expected.calc_map(equal_aspect=True)
        fli = image.load(fl, dpi=254)  # 254 pix/in => 1 pix/0.1mm (default fluence calc)

        # equate them such that they're the same size & DPI
        fluence_img, img_array = image.equate_images(fli, self.image)
        self.image.array = img_array.array

        # get picket fits from the modified fluence image
        pf = PicketFence.from_demo_image()
        pf.image = fluence_img
        pf.analyze()
        self._log_fits = cycle([p.get_fit() for p in pf.pickets])

    @staticmethod
    def run_demo(tolerance: float = 0.5, action_tolerance: float = None) -> None:
        """Run the Picket Fence demo using the demo image. See analyze() for parameter info."""
        pf = PicketFence.from_demo_image()
        pf.analyze(tolerance, action_tolerance=action_tolerance)
        print(pf.results())
        pf.plot_analyzed_image(leaf_error_subplot=True)

    def analyze(self, tolerance: float = 0.5, action_tolerance: Optional[float] = None,
                num_pickets: Optional[int] = None, sag_adjustment: Union[float, int] = 0,
                orientation: Optional[Union[Orientation, str]] = None, invert: bool = False, leaf_analysis_width_ratio: float = 0.4,
                picket_spacing: Optional[float] = None, height_threshold: float = 0.5, edge_threshold: float = 1.5,
                peak_sort: str = 'peak_heights', required_prominence: float = 0.2, fwxm: int = 50,
                separate_leaves: bool = False, nominal_gap_mm: float = 3) -> None:
        """Analyze the picket fence image.

        Parameters
        ----------
        tolerance
            The tolerance of difference in mm between an MLC pair position and the
            picket fit line.
        action_tolerance
            If None (default), no action tolerance is set or compared to.
            If an int or float, the MLC pair measurement is also compared to this
            tolerance. Must be lower than tolerance. This value is usually meant
            to indicate that a physicist should take an "action" to reduce the error,
            but should not stop treatment.
        num_pickets
            The number of pickets in the image. A helper parameter to limit the total number of pickets,
            only needed if analysis is catching more pickets than there really are.
        sag_adjustment
            The amount of shift in mm to apply to the image to correct for EPID sag.
            For Up-Down picket images, positive moves the image down, negative up.
            For Left-Right picket images, positive moves the image left, negative right.
        orientation
            If None (default), the orientation is automatically determined. If for some reason the determined
            orientation is not correct, you can pass it directly using this parameter.
            If passed a string with 'u' (e.g. 'up-down', 'u-d', 'up') it will set the orientation of the pickets as
            going up-down. If passed a string with 'l' (e.g. 'left-right', 'lr', 'left') it will set it as going
            left-right.
        invert
            If False (default), the inversion of the image is automatically detected and used.
            If True, the image inversion is reversed from the automatic detection. This is useful when runtime errors
            are encountered.
        leaf_analysis_width_ratio
            The ratio of the leaf width to use as part of the evaluation. E.g. if the ratio is 0.5, the center half of
            the leaf will be used. This helps avoid tongue and groove influence.
        picket_spacing
            If None (default), the spacing between pickets is determined automatically.
            If given, it should be an int or float specifying the number of **PIXELS** apart the pickets are.
        height_threshold
            The threshold that the MLC peak needs to be above to be considered a picket (vs background).
            Lower if not all leaves are being caught. Note that for FFF beams this would very likely need to be lowered.
        edge_threshold
            The threshold of pixel value standard deviation within the analysis window of the MLC leaf to be considered a full leaf.
            This is how pylinac removes MLCs that are eclipsed by the jaw. This also is how to
            omit or catch leaves at the edge of the field. Raise to catch more edge leaves.
        peak_sort
            Either 'peak_heights' or 'prominences'. This is the method for determining the peaks. Usually not needed
            unless the wrong number of pickets have been detected.
            See the scipy.signal.find_peaks function for more information.
        required_prominence
            The required height of the picket (not individual MLCs) to be considered a peak.
            Pylinac takes a mean of the image axis perpendicular to the leaf motion to get an initial guess of the peak
            locations and also to determine picket spacing. Changing this can be useful for wide-gap tests where
            the shape of the beam horns can form two or more local maximums in the picket area. Increase if for wide-gap
            images that are catching too many pickets. Consider lowering for FFF beams if there are analysis issues.

            .. warning::

                We do not recommend performing FFF wide-gap PF tests. Make your FFF pickets narrow or measure with a flat beam instead.

        fwxm
            For each MLC kiss, the profile is a curve from low to high to low. The FWXM (0-100) is the height to use to measure
            to determine the center of the curve, which is the surrogate for MLC kiss position. I.e. for each MLC kiss,
            what height of the picket should you use to actually determine the center location? It is unusual to change this.
            If you have something in the way (we've seen crazy examples with a BB in the way) you may want to increase this.
        separate_leaves
            Whether to analyze leaves individually (each tip) or as a set (combined, center of the picket). False is
            the default for backwards compatibility.
        nominal_gap_mm
            The expected gap of the pickets in mm. Only used when separate leaves is True. Due to the DLG and EPID
            scattering, this value will have to be determined by you with a known good delivery.
        """
        if action_tolerance is not None and tolerance < action_tolerance:
            raise ValueError("Tolerance cannot be lower than the action tolerance")
        self.tolerance = tolerance
        self.action_tolerance = action_tolerance
        self.leaf_analysis_width = leaf_analysis_width_ratio
        self.separate_leaves = separate_leaves

        if invert:
            self.image.invert()

        self._orientation = orientation
        # adjust for sag
        if sag_adjustment != 0:
            sag_pixels = int(round(sag_adjustment * self.image.dpmm))
            self.image.adjust_for_sag(sag_pixels, self.orientation)

        if self.orientation == Orientation.UP_DOWN:
            leaf_prof = np.mean(self.image, 0)
        else:
            leaf_prof = np.mean(self.image, 1)
        leaf_prof = MultiProfile(leaf_prof)
        peak_idxs, peak_vals = leaf_prof.find_fwxm_peaks(min_distance=0.02, threshold=height_threshold, max_number=num_pickets,
                                                         peak_sort=peak_sort, required_prominence=required_prominence)
        if len(peak_idxs) == 0:
            raise ValueError("No pickets were found. This can mean either an incorrect orientation or incorrect inversion. "
                             "Try passing the correct orientation; if that fails, also set invert=True.")
        # get picket spacing if not set by user
        if picket_spacing is None:
            picket_spacing = np.median(np.diff(np.sort(peak_idxs)))

        # loop through each leaf row and analyze each MLC kiss
        self.mlc_meas = []
        for leaf_num, center, width in self._leaves_in_view(leaf_analysis_width_ratio):
            for picket_num, (picket_idx, picket_peak_val) in enumerate(zip(peak_idxs, peak_vals)):
                window = self._get_mlc_window(leaf_center=center, leaf_width=width, approx_idx=picket_idx,
                                              spacing=picket_spacing)
                if self._is_mlc_peak_in_window(window, height_threshold, edge_threshold, picket_peak_val):
                    self.mlc_meas.append(MLCValue(picket_num=picket_num, approx_idx=picket_idx, leaf_width=width,
                                                  leaf_center=center, picket_spacing=picket_spacing,
                                                  orientation=self.orientation,
                                                  leaf_analysis_width_ratio=leaf_analysis_width_ratio,
                                                  tolerance=tolerance, action_tolerance=action_tolerance,
                                                  leaf_num=leaf_num, approx_peak_val=picket_peak_val,
                                                  image_window=window, image=self.image, fwxm=fwxm,
                                                  separate_leaves=separate_leaves, nominal_gap_mm=nominal_gap_mm,
                                                  ))
        if not self.mlc_meas:
            raise ValueError("No MLC measurements were found. This may be due to an incorrect inversion. Try setting invert=True. Or, you may have passed an incorrect orientation.")

        # drop any leaf rows that don't have the right amount of MLC kisses (i.e. near edge where one is dropped)
        median_num_leaves = Enumerable(self.mlc_meas) \
            .group_by(key=lambda m: m.leaf_num) \
            .median(lambda m: len(m))
        full_leaves = Enumerable(self.mlc_meas) \
            .group_by(key=lambda m: m.leaf_num) \
            .where(lambda m: len(m) == median_num_leaves) \
            .select_many(lambda m: m) \
            .select(lambda m: m.leaf_num) \
            .distinct() \
            .to_list()
        self.mlc_meas = [m for m in self.mlc_meas if m.leaf_num in full_leaves]
        if any([True for m in self.mlc_meas if m.leaf_num not in full_leaves]):
            warnings.warn("Some leaves were removed from analysis because they were not detected for all pickets. If some valid leaves are missing try adjusting height_threshold or edge_threshold")

        # retrospectively create the pickets and update the individual MLC measurements so error can be calculated
        self.pickets = []
        for picket_num, _ in enumerate(peak_idxs):
            self.pickets.append(Picket([m for m in self.mlc_meas if m.picket_num == picket_num],
                                       log_fits=self._log_fits, orientation=self.orientation,
                                       image=self.image, tolerance=tolerance, nominal_gap=nominal_gap_mm,
                                       separate_leaves=separate_leaves))

        self._is_analyzed = True

    def _is_mlc_peak_in_window(self, window, height_threshold, edge_threshold, picket_peak_val) -> bool:
        """Whether the MLC peak is inside the given window. E.g. the jaw could be closed or at an edge."""
        if self.orientation == Orientation.UP_DOWN:
            std = np.std(window, axis=1)
        else:
            std = np.std(window, axis=0)
        is_above_height_threshold = np.max(window) > height_threshold * picket_peak_val
        is_not_at_edge = max(std) < edge_threshold * np.median(std)
        return is_above_height_threshold and is_not_at_edge

    def _get_mlc_window(self, leaf_center, leaf_width, approx_idx, spacing) -> np.ndarray:
        """A small 2D window of the image that contains the area around the picket."""
        leaf_width_px = leaf_width * self.image.dpmm
        leaf_center_px = leaf_center * self.image.dpmm + (
            self.image.shape[0] / 2 if self.orientation == Orientation.UP_DOWN else self.image.shape[1] / 2)
        if self.orientation == Orientation.UP_DOWN:
            # crop edges to image boundary if need be; if the pickets are too close to edge we could spill outside
            left_edge = max(int(approx_idx - spacing / 2), 0)
            right_edge = min(int(approx_idx + spacing / 2), self.image.shape[1])
            top_edge = max(int(leaf_center_px - leaf_width_px / 2), 0)
            bottom_edge = min(int(leaf_center_px + leaf_width_px / 2), self.image.shape[0])
            array = self.image[top_edge:bottom_edge, left_edge:right_edge]
        else:
            top_edge = max(int(approx_idx - spacing / 2), 0)
            bottom_edge = min(int(approx_idx + spacing / 2), self.image.shape[0])
            left_edge = max(int(leaf_center_px - leaf_width_px / 2), 0)
            right_edge = min(int(leaf_center_px + leaf_width_px / 2), self.image.shape[1])
            array = self.image[top_edge:bottom_edge, left_edge:right_edge]
        return array

    def _leaves_in_view(self, analysis_width) -> List[Tuple[int, int, int]]:
        """Crop the leaves if not all leaves are in view."""
        range = self.image.shape[0] / 2 if self.orientation == Orientation.UP_DOWN else self.image.shape[1] / 2
        # cut off the edge so that we're not halfway through a leaf.
        range -= max(self.mlc.widths[0] * analysis_width, self.mlc.widths[-1] * analysis_width) * self.image.dpmm
        leaves = [i for i, c in enumerate(self.mlc.centers) if abs(c) < (range / self.image.dpmm)]
        return [(leaf_num, center, width) for leaf_num, center, width in
                zip(leaves, self.mlc.centers[leaves[0]:leaves[-1] + 1], self.mlc.widths[leaves[0]:leaves[-1] + 1])]

    def plot_analyzed_image(self, guard_rails: bool = True, mlc_peaks: bool = True, overlay: bool = True,
                            leaf_error_subplot: bool = True, show: bool = True,
                            figure_size: Union[str, Tuple] = 'auto') -> None:
        """Plot the analyzed image.

        Parameters
        ----------
        guard_rails
            Do/don't plot the picket "guard rails" around the ideal picket
        mlc_peaks
            Do/don't plot the detected MLC peak positions.
        overlay
            Do/don't plot the alpha overlay of the leaf status.
        leaf_error_subplot
            If True, plots a linked leaf error subplot adjacent to the PF image plotting the average and standard
            deviation of leaf error.
        show
            Whether to display the plot. Set to false for saving to a figure, etc.
        figure_size
            Either 'auto' or a tuple. If auto, the figure size is set depending on the orientation. If a tuple, this is the
            figure size to use.
        """
        if not self._is_analyzed:
            raise RuntimeError("The image must be analyzed first. Use .analyze().")

        # plot the image
        if figure_size == 'auto':
            if self.orientation == Orientation.UP_DOWN:
                figure_size = (12, 8)
            else:
                figure_size = (9, 9)
        fig, ax = plt.subplots(figsize=figure_size)
        ax.imshow(self.image.array, cmap=get_dicom_cmap())

        if leaf_error_subplot:
            self._add_leaf_error_subplot(ax)

        if guard_rails:
            for picket in self.pickets:
                picket.add_guards_to_axes(ax.axes)
        if mlc_peaks:
            for mlc_meas in self.mlc_meas:
                mlc_meas.plot2axes(ax.axes, width=1.5)

        if overlay:
            for mlc_meas in self.mlc_meas:
                mlc_meas.plot_overlay2axes(ax.axes)

        # plot CAX
        ax.plot(self.image.center.x, self.image.center.y, 'r+', ms=12, markeredgewidth=3)

        # tighten up the plot view
        ax.set_xlim([0, self.image.shape[1]])
        ax.set_ylim([0, self.image.shape[0]])
        ax.axis('off')

        if show:
            plt.show()

    def _add_leaf_error_subplot(self, ax: plt.Axes) -> None:
        """Add a bar subplot showing the leaf error."""

        # make the new axis
        divider = make_axes_locatable(ax)
        if self.orientation == Orientation.UP_DOWN:
            axtop = divider.append_axes('right', 2, pad=1, sharey=ax)
        else:
            axtop = divider.append_axes('bottom', 2, pad=1, sharex=ax)

        # get leaf positions, errors, standard deviation, and leaf numbers
        if self.orientation == Orientation.UP_DOWN:
            pos = [position.marker_lines[0].center.y for position in self.pickets[0].mlc_meas]
        else:
            pos = [position.marker_lines[0].center.x for position in self.pickets[0].mlc_meas]

        # calculate the error and stdev values per MLC pair
        error_stdev = []
        error_vals = []
        for leaf_num in set([m.leaf_num for m in self.mlc_meas]):
            error_vals.append(np.mean([np.abs(m.error) for m in self.mlc_meas if m.leaf_num == leaf_num]))
            error_stdev.append(np.std([m.error for m in self.mlc_meas if m.leaf_num == leaf_num]))

        # plot the leaf errors as a bar plot
        if self.orientation == Orientation.UP_DOWN:
            axtop.barh(pos, error_vals, xerr=error_stdev, height=self.leaf_analysis_width * 2, alpha=0.4,
                       align='center')
            # plot the tolerance line(s)
            axtop.axvline(self.tolerance, color='r', linewidth=3)
            if self.action_tolerance is not None:
                axtop.axvline(self.action_tolerance, color='m', linewidth=3)
            # reset xlims to comfortably include the max error or tolerance value
            axtop.set_xlim([0, max([max(error_vals)+max(error_stdev), self.tolerance]) + 0.1])
        else:
            axtop.bar(pos, error_vals, yerr=error_stdev, width=self.leaf_analysis_width * 2, alpha=0.4, align='center')
            # plot the tolerance line(s)
            axtop.axhline(self.tolerance, color='r', linewidth=3)
            if self.action_tolerance is not None:
                axtop.axhline(self.action_tolerance, color='m', linewidth=3)
            axtop.set_ylim([0, max([max(error_vals)+max(error_stdev), self.tolerance]) + 0.1])

        axtop.grid(True)
        axtop.set_title("Average Error (mm)")

    def save_analyzed_image(self, filename: Union[str, io.BytesIO], guard_rails: bool = True, mlc_peaks: bool = True, overlay: bool = True,
                            leaf_error_subplot: bool = False, **kwargs) -> None:
        """Save the analyzed figure to a file. See :meth:`~pylinac.picketfence.PicketFence.plot_analyzed_image()` for
        further parameter info.
        """
        self.plot_analyzed_image(guard_rails, mlc_peaks, overlay, leaf_error_subplot=leaf_error_subplot, show=False)
        plt.savefig(filename, **kwargs)
        if isinstance(filename, str):
            print(f"Picket fence image saved to: {osp.abspath(filename)}")

    def results(self) -> str:
        """Return results of analysis. Use with print()."""
        pass_pct = self.percent_passing
        offsets = ' '.join('{:.1f}'.format(pk.dist2cax) for pk in self.pickets)
        string = f"Picket Fence Results: \n{pass_pct:2.1f}% " \
                 f"Passed\nMedian Error: {self.abs_median_error:2.3f}mm \n" \
                 f"Mean picket spacing: {self.mean_picket_spacing:2.1f}mm \n" \
                 f"Picket offsets from CAX (mm): {offsets}\n" \
                 f"Max Error: {self.max_error:2.3f}mm on Picket: {self.max_error_picket}, Leaf: {self.max_error_leaf}\n" \
                 f"Failing leaves: {self.failed_leaves()}"
        return string

    def results_data(self, as_dict=False) -> Union[PFResult, dict]:
        """Present the results data and metadata as a dataclass, dict, or tuple.
        The default return type is a dataclass."""
        data = PFResult(tolerance_mm=self.tolerance,
                        action_tolerance_mm=self.action_tolerance,
                        percent_leaves_passing=self.percent_passing,
                        number_of_pickets=self.num_pickets,
                        absolute_median_error_mm=self.abs_median_error,
                        max_error_mm=self.max_error,
                        mean_picket_spacing_mm=self.mean_picket_spacing,
                        offsets_from_cax_mm=[pk.dist2cax for pk in self.pickets],
                        passed=self.passed,
                        failed_leaves=self.failed_leaves(),
                        mlc_skew=self.mlc_skew())
        if as_dict:
            return dataclasses.asdict(data)
        return data

    def publish_pdf(self, filename: Union[str, io.BytesIO], notes: str = None, open_file: bool = False, metadata: dict = None, bins: int = 10) -> None:
        """Publish (print) a PDF containing the analysis, images, and quantitative results.

        Parameters
        ----------
        filename : (str, file-like object}
            The file to write the results to.
        notes : str, list of strings
            Text; if str, prints single line.
            If list of strings, each list item is printed on its own line.
        open_file : bool
            Whether to open the file using the default program after creation.
        metadata : dict
            Extra data to be passed and shown in the PDF. The key and value will be shown with a colon.
            E.g. passing {'Author': 'James', 'Unit': 'TrueBeam'} would result in text in the PDF like:
            --------------
            Author: James
            Unit: TrueBeam
            --------------
        """
        plt.ioff()
        canvas = pdf.PylinacCanvas(filename, page_title="Picket Fence Analysis", metadata=metadata)
        data = io.BytesIO()
        self.save_analyzed_image(data, leaf_error_subplot=True)
        canvas.add_image(data, location=(3, 8), dimensions=(15, 15))
        text = [
            'Picket Fence results:',
            f'Magnification factor (SID/SAD): {self.image.metadata.RTImageSID / self.image.metadata.RadiationMachineSAD:2.2f}',
            f'Tolerance (mm): {self.tolerance}',
            f'Leaves passing (%): {self.percent_passing:2.1f}',
            f'Absolute median error (mm): {self.abs_median_error:2.3f}',
            f'Mean picket spacing (mm): {self.mean_picket_spacing:2.1f}',
            f'Maximum error (mm): {self.max_error:2.3f} on Picket {self.max_error_picket}, Leaf {self.max_error_leaf}',
        ]
        if self.failed_leaves():
            text += textwrap.wrap(f"Failing leaves: {self.failed_leaves()}")
        text.append(f'Gantry Angle: {self.image.gantry_angle:2.2f}')
        text.append(f'Collimator Angle: {self.image.collimator_angle:2.2f}')
        canvas.add_text(text=text, location=(10, 25.5))
        if notes is not None:
            canvas.add_text(text="Notes:", location=(1, 5.5), font_size=14)
            canvas.add_text(text=notes, location=(1, 5))

        canvas.add_new_page()
        hist = io.BytesIO()
        self.save_histogram(hist, bins)
        canvas.add_image(hist, location=(3, 8), dimensions=(15, 15))
        canvas.finish()

        if open_file:
            open_path(filename)

    def mlc_skew(self) -> float:
        """Apparent rotation in degrees of the MLC. This could be conflated with the EPID skew, so be careful when interpreting this value."""
        return Enumerable(self.pickets).avg(lambda p: np.rad2deg(np.arctan(p.fit.c[0])))

    def plot_histogram(self, bins: int = 10, show: bool = True) -> None:
        """Plot a histogram of the leaf errors"""
        if not self._is_analyzed:
            raise ValueError("It appears the PF image has not been analyzed yet. Use .analyze() first.")
        errors = Enumerable(self.mlc_meas).select_many(lambda m: m.error).to_list()
        fig, ax = plt.subplots()
        ax.set_title("Leaf error histogram")
        ax.set_ylabel("Counts")
        ax.set_xlabel("Error (mm)")
        ax.hist(errors, bins=bins)
        if show:
            plt.show()

    def save_histogram(self, filename: [str, Path, BinaryIO], bins: int = 10, **kwargs) -> None:
        """Save a histogram of the leaf errors"""
        self.plot_histogram(bins, show=False)
        plt.savefig(filename, **kwargs)
        if not isinstance(filename, BytesIO):
            print(f"Picket fence histogram saved to: {osp.abspath(filename)}")

    @cached_property
    def orientation(self) -> Orientation:
        """The orientation of the image, either Up-Down or Left-Right."""
        # if orientation was passed in, use it
        if self._orientation is not None:
            return convert_to_enum(self._orientation, Orientation)

        # replace any dead pixels with median value
        temp_image = self.image.array.copy()
        temp_image[temp_image < np.median(temp_image)] = np.median(temp_image)

        # find "range" of 80 to 90th percentiles
        row_sum = np.sum(temp_image, 0)
        col_sum = np.sum(temp_image, 1)
        row80, row90 = np.percentile(row_sum, [85, 99])
        col80, col90 = np.percentile(col_sum, [85, 99])
        row_range = row90 - row80
        col_range = col90 - col80

        # The true picket side will have a greater difference in
        # percentiles than will the non-picket size.
        if row_range < col_range:
            orientation = Orientation.LEFT_RIGHT
        else:
            orientation = Orientation.UP_DOWN
        return orientation class Orientation(enum.Enum):
    """Possible orientations of the image"""
    UP_DOWN = 'Up-Down'  #:
    LEFT_RIGHT = 'Left-Right'  #: class PFResult(ResultBase):
    """This class should not be called directly. It is returned by the ``results_data()`` method.
    It is a dataclass under the hood and thus comes with all the dunder magic.

    Use the following attributes as normal class attributes."""
    tolerance_mm: float  #:
    action_tolerance_mm: float  #:
    percent_leaves_passing: float  #:
    number_of_pickets: int  #:
    absolute_median_error_mm: float  #:
    max_error_mm: float  #:
    mean_picket_spacing_mm: float  #:
    offsets_from_cax_mm: List[float]  #:
    passed: bool  #:
    failed_leaves: Union[List[str], List[int]]  #:
    mlc_skew: float  #: class MLCArrangement:
    """Construct an MLC array"""

    def __init__(self, leaf_arrangement: List[Tuple[int, float]], offset: float = 0):
        """

        Parameters
        ----------
        leaf_arrangement: Description of the leaf arrangement. List of tuples containing the number of leaves and leaf width.
            E.g. (10, 5) is 10 leaves with 5mm widths.
        offset: The offset in mm of the leaves. Used for asymmetric arrangements. E.g. -2.5mm will shift the arrangement 2.5mm to the left.
        """
        self.centers = []
        self.widths = []
        rolling_edge = 0
        for leaf_num, width in leaf_arrangement:
            self.centers += np.arange(start=rolling_edge + width / 2, stop=leaf_num * width + rolling_edge + width / 2,
                                      step=width).tolist()
            rolling_edge = self.centers[-1] + width / 2
            self.widths += [width] * leaf_num
        self.centers = [c - np.mean(self.centers) + offset for c in self.centers] def save_file(method, *args, as_file_object=None, **kwargs):
    """Save a file using the passed method and assert it exists after saving.
    Also deletes the file after checking for existence."""
    if as_file_object is None:  # regular file
        with TemporaryDirectory() as tmpdir:
            tmpfile = osp.join(tmpdir, "myfile")
            method(tmpfile, *args, **kwargs)
    else:
        if "b" in as_file_object:
            temp = BytesIO
        elif "s" in as_file_object:
            temp = StringIO
        with temp() as t:
            method(t, *args, **kwargs) class CloudFileMixin:
    """A mixin that provides attrs and a method to get the absolute location of the
    file using syntax.

    Usage
    -----
    0. Override ``dir_location`` with the directory of the destination.
    1. Override ``file_path`` with a list that contains the subfolder(s) and file name.
    """

    file_name: str
    dir_path: Sequence[str]
    delete_file = True

    @classmethod
    def get_filename(cls) -> str:
        """Return the canonical path to the file on disk. Download if it doesn't exist."""
        full_path = Path(LOCAL_TEST_DIR, *cls.dir_path, cls.file_name).absolute()
        if full_path.is_file():
            return str(full_path)
        else:
            return get_file_from_cloud_test_repo([*cls.dir_path, cls.file_name])

    @classmethod
    def tearDownClass(cls):
        if cls.delete_file and DELETE_FILES:
            file = cls.get_filename()
            if osp.isfile(file):
                os.remove(file)
            elif osp.isdir(file):
                shutil.rmtree(file) def get_file_from_cloud_test_repo(path: List[str], force: bool = False) -> str:
    """Get a single file from GCP storage. Returns the path to disk it was downloaded to"""
    local_filename = osp.join(osp.dirname(__file__), LOCAL_TEST_DIR, *path)
    if osp.isfile(local_filename) and not force:
        return local_filename
    with access_gcp() as client:
        bucket = client.bucket(GCP_BUCKET_NAME)
        blob = bucket.blob(
            str(PurePosixPath(*path))
        )  # posix because google storage is on unix and won't find path w/ windows path
        # make any necessary subdirs leading up to the file
        if len(path) > 1:
            for idx in range(1, len(path)):
                local_dir = osp.join(osp.dirname(__file__), LOCAL_TEST_DIR, *path[:idx])
                if not osp.isdir(local_dir):
                    os.mkdir(local_dir)

        blob.download_to_filename(local_filename)
        return local_filename class InitTesterMixin(MixinTesterBase):
    init_file: List[str]
    init_kwargs: dict = {}
    is_folder = False

    @property
    def full_init_file(self) -> str:
        if self.is_folder:
            return get_folder_from_cloud_test_repo(self.init_file)
        else:
            return get_file_from_cloud_test_repo(self.init_file)

    def test_init(self):
        inst = self.klass(self.full_init_file, **self.init_kwargs)
        self.assertIsInstance(inst, self.klass) class FromURLTesterMixin(MixinTesterBase):
    url: str
    url_kwargs: dict = {}

    @property
    def full_url(self):
        return r"https://storage.googleapis.com/pylinac_demo_files/" + self.url

    def test_from_url(self):
        self.klass.from_url(self.full_url, **self.url_kwargs) class FromDemoImageTesterMixin(MixinTesterBase):
    demo_load_method: str = "from_demo_image"

    def test_from_demo(self):
        inst = getattr(self.klass, self.demo_load_method)()
        self.assertIsInstance(inst, self.klass)
import io
import os
import os.path as osp
import tempfile
import matplotlib.pyplot as plt
from unittest import TestCase, skip
from pylinac.picketfence import PicketFence, Orientation, PFResult, MLCArrangement
from tests_basic.utils import save_file, CloudFileMixin, get_file_from_cloud_test_repo, InitTesterMixin, \
    FromURLTesterMixin, FromDemoImageTesterMixin
        # below shouldn't raise
        # as enum
        pf = PicketFence.from_demo_image()
        pf.analyze(orientation=Orientation.UP_DOWN)

        # as str
        pf = PicketFence.from_demo_image()
        pf.analyze(orientation="Up-Down")

    def test_histogram(self):
        pf = PicketFence.from_demo_image()
        pf.analyze()
        pf.plot_histogram()

        pf2 = PicketFence.from_demo_image()
        # can't plot before analyzing
        with self.assertRaises(ValueError):
            pf2.plot_histogram()

    def test_failed_leaves_before_analyzed(self):
        pf = PicketFence.from_demo_image()
        with self.assertRaises(ValueError):
            pf.failed_leaves()

    def test_failed_leaves_traditional(self):
        pf = PicketFence.from_demo_image()
        pf.analyze(separate_leaves=False, tolerance=0.05)
        self.assertEqual(
            set(pf.failed_leaves()),
            {12, 14, 16, 17, 18, 19, 26, 29, 31, 32, 33, 35, 39, 42, 43, 47},
        )

    def test_failed_leaves_separate(self):
        pf = PicketFence.from_demo_image()
        pf.analyze(separate_leaves=True, tolerance=0.15, nominal_gap_mm=3)
        self.assertEqual(
            set(pf.failed_leaves()),
            {
                "A12",
                "B12",
                "A13",
                "B13",
                "A14",
                "B14",
                "A15",
                "B15",
                "B16",
                "B17",
                "A18",
                "B18",
                "B19",
                "A19",
                "A20",
                "B20",
                "B22",
                "A23",
                "B23",
                "A24",
                "B24",
                "A25",
                "B26",
                "A27",
                "B27",
                "A28",
                "B28",
                "B29",
                "B30",
                "B31",
                "A32",
                "B32",
                "A33",
                "B34",
                "B35",
                "A35",
                "A36",
                "B36",
                "A37",
                "B37",
                "A38",
                "B38",
                "B39",
                "A39",
                "A40",
                "B40",
                "B41",
                "A41",
                "B42",
                "A43",
                "B43",
                "B44",
                "A44",
                "B45",
                "A45",
                "A46",
                "B46",
                "B47",
                "A47",
            },
        )


class TestPlottingSaving(TestCase):
    @classmethod
    def setUpClass(cls):
        cls.pf = PicketFence.from_demo_image()
        cls.pf.analyze()
        cls.pf_updown = PicketFence.from_demo_image()
        cls.pf_updown.image.rot90()
        cls.pf_updown.analyze()

    @classmethod
    def tearDownClass(cls):
        plt.close("all")

    def test_plotting(self):
        self.pf.plot_analyzed_image()
        self.pf_updown.plot_analyzed_image()

    def test_saving_image(self):
        save_file(self.pf.save_analyzed_image)

        save_file(self.pf_updown.save_analyzed_image)