"""The main public API to the data in a FLIR thermogram and the result of
`flyr.unpack()`.
"""

import os
import warnings
from math import exp, fsum, sqrt
from typing import Dict, List, Literal, Optional, Tuple, Union

import numpy as np
from PIL import Image, ImageFilter

import flyr.camera_metadata as cm
import flyr.edges as edg
import flyr.measurement_info as mi
import flyr.msx as msx
import flyr.normalization as norm
import flyr.palette_info as pal
import flyr.palettes as palettes
import flyr.picture_in_picture as pip
from flyr.dtypes import (
    Generic2DArray,
    MaskArray,
    RawArray,
    RGBArray,
    Temperature,
)


class FlyrThermogram:
    """A FlyrThermogram is a class providing read-only access to the data in a typical
    FLIR thermogram.

    Specifically interesting are:

    * `kelvin` (property): Getting the temperature in degrees kelvin
    * `celsius` (property): Getting the temperature in degrees celsius
    * `metadata` (property): Getting the FLIR camera metadata, some of which
        influences how `kelvin`/`celsius` are calculated.
    * `adjust_metadata()` (method): Updates the above-mentioned metadata and can thus
        be used to change how the temperature is calculated.
    * `render()` (method): Returns the temperature as an RGB array. Use the
        `render_pil` variant to get one
    """

    # Required members variables required to be set
    __metadata_adjustments: Dict[str, Union[float, int]]
    __metadata: Dict[str, Union[float, int]]
    __optical: Optional[RGBArray]
    __thermal: RawArray

    camera_metadata: Optional[cm.CameraMetadata]
    measurements: List[mi.Measurement]
    palette: Optional[pal.Palette]
    path: Optional[str]
    pip_info: Optional[pip.PictureInPicture]

    def __init__(
        self,
        thermal: RawArray,
        metadata: Dict[str, Union[float, int]],
        optical: Optional[RGBArray] = None,
        palette: Optional[pal.Palette] = None,
        picture_in_picture: Optional[pip.PictureInPicture] = None,
        path: Optional[str] = None,
        metadata_adjustments: Dict[str, Union[float, int]] = {},
        metadata_camera: Optional[cm.CameraMetadata] = None,
        measurements: List[mi.Measurement] = [],
    ):
        """Initialize a new instance of this class. The raw thermal data and
        the accompanying metadata to correctly interpret this data are
        required.

        Parameters
        ----------
        thermal: RawArray
            A 2D numpy array of 64 or 16 bit integers. This is the raw thermal
            data as it is stored in the FLIR file. Order is [H, W].
        metadata: Dict[str, Union[float, int]]
            A dictionary with physical parameters to interpret the raw
            thermal data, as it was included in the original FLIR file.
        optical: RGBArray
            A 3D numpy array of 8 bit integers, in the order of [H, W, C].
            This should be a 'normal' photo (RGB) of the same scene as
            thermogram.
        palette: Optional[pinfo.Palette]
            The palette embedded in the file, which can be used to render
            with by default. Is `None` by default in which case a grayscale
            palette is used.
        picture_in_picture: Optional[pip.PictureInPicture]
            The picture-in-picture settings useful for stacking optical and
            thermal data.
        path: Optional[str]
            The path to the original file. Is only used to determine this
            this object's identifier and can safely be left `None`
            (default).
        metadata_adjustments: Dict[str, Union[float, int]]
            A dictionary with adjustments to the above dictionary. This
            separation allows both using the original settings and
            calculating temperatures with different physical parameters.
        metadata_camera: Optional[cm.CameraMetadata]
        measurements: List[mi.Measurement]

        Returns
        -------
        FlyrThermogram
        """
        self.__metadata = metadata.copy()
        self.__metadata_adjustments = metadata_adjustments.copy()
        self.__optical = optical  # Optical (RGB) photo in the thermogram
        self.__thermal = thermal  # Raw thermal data
        self.camera_metadata = metadata_camera
        self.measurements = measurements
        self.palette = palette
        self.path = path
        self.pip_info = picture_in_picture

    @property
    def kelvin(self) -> Generic2DArray[np.float64]:
        """A property method that returns the thermogram's temperature in
        kelvin (K).

        Returns
        -------
        Generic2DArray[np.float64]
            A 2D array of numpy float values in kelvin. Order is [H, W].
        """
        return self.__raw_to_kelvin_with_metadata(self.__thermal)

    @property
    def celsius(self) -> Generic2DArray[np.float64]:
        """Returns the thermogram's temperature in degrees celsius (°C).

        Returns
        -------
        Generic2DArray[np.float64]
            A 2D array of numpy float values in celsius. Order is [H, W].
        """
        return self.kelvin - 273.15

    @property
    def fahrenheit(self) -> Generic2DArray[np.float64]:
        """Returns the thermogram's temperature in degrees fahrenheit (°F).

        Returns
        -------
        Generic2DArray[np.float64]
            A 2D array of numpy float values in fahrenheit. Order is [H, W].
        """
        return self.celsius * 1.8 + 32.00

    @property
    def reaumur(self) -> Generic2DArray[np.float64]:
        """Returns the thermogram's temperature in degrees reaumur (°Re), a
        historical scale preceding Celsius proposed in 1730.

        Returns
        -------
        Generic2DArray[np.float64]
            A 2D array of numpy float values in fahrenheit. Order is [H, W].
        """
        return self.celsius * 0.8

    @property
    def rankine(self) -> Generic2DArray[np.float64]:
        """Returns the thermogram's temperature in degrees rankine (°Ra), a
        historical scale from 1859 similar to Fahrenheit.

        Returns
        -------
        Generic2DArray[np.float64]
            A 2D array of numpy float values in fahrenheit. Order is [H, W].
        """
        return self.kelvin * 1.8

    @property
    def delisle(self) -> Generic2DArray[np.float64]:
        """Returns the thermogram's temperature in degrees rankine (°D), a
        historical scale from 1732.

        Returns
        -------
        Generic2DArray[np.float64]
            A 2D array of numpy float values in fahrenheit. Order is [H, W].
        """
        return (375.15 - self.kelvin) * 1.5

    @property
    def newton(self) -> Generic2DArray[np.float64]:
        """Returns the thermogram's temperature in degrees Newton (°N), a
        historical scale devised in 1701.

        Returns
        -------
        Generic2DArray[np.float64]
            A 2D array of numpy float values in fahrenheit. Order is [H, W].
        """
        return self.celsius * (33 / 100)

    @property
    def roemer(self) -> Generic2DArray[np.float64]:
        """Returns the thermogram's temperature in degrees Rømer (°Rø), a
        historical scale from 1702.

        Returns
        -------
        Generic2DArray[np.float64]
            A 2D array of numpy float values in fahrenheit. Order is [H, W].
        """
        return self.celsius * (21 / 40) + 7.5

    @property
    def optical(self) -> Optional[RGBArray]:
        """Returns the thermogram's embedded photo.

        Returns
        -------
        RGBArray or `None`
            A 3D array of 8 bit integers containing the RGB photo
            embedded within the FLIR thermogram.  Order is [H, W, C].
        """
        return None if self.__optical is None else self.__optical.copy()

    @property
    def optical_pil(self) -> Optional[Image.Image]:
        """Returns the thermogram's embedded photo as a Pillow `Image`.

        Returns
        -------
        `PIL.Image`
            A Pillow Image object of the RGB photo embedded within the FLIR
            thermogram.
        """
        optical = self.optical
        return None if optical is None else Image.fromarray(optical)

    @property
    def metadata(self) -> Dict[str, Union[float, int]]:
        metadata = self.__metadata.copy()
        metadata.update(self.__metadata_adjustments)
        return metadata

    @property
    def identifier(self) -> Optional[str]:
        return os.path.basename(self.path) if self.path is not None else None

    def render(
        self,
        min_v: Optional[float] = None,
        max_v: Optional[float] = None,
        unit: Temperature = "kelvin",
        palette: Union[palettes.Palette, palettes.PaletteColors] = "embedded",
        mode: norm.NormalizationMode = "minmax",
        histogram_equalized: bool = False,
    ) -> RGBArray:
        """Renders the thermogram to RGB with the given settings.

        First the thermogram is normalized using the given interval and
        mode. Then the palette is used to translate the values to colors.

        Parameters
        ----------
        min_v: float or `None`. Is `None` by default.
            When set to `None`, the lower bound the FLIR camera used to render the
            original file with is used.
                All values below this value will be clipped to this value,
            although the exact behaviour depends on the `unit`.
                When unit is celsius, kelvin or fahrenheit, the `min_v` and `max_v`
            values function directly as the thresholds to which the thermogram is
            clipped.
                When `unit='percentiles'`, then `min_v` and `max_v` are interpreted
            as percentiles. First the values for those percentiles are retrieved
            which are then used to clip the thermogram as described when
            `unit='kelvin'`.
        max_v: float or `None`. Is `None` by default.
            See the `min_v` for details on how it is interpreted.
        unit: Temperature
            The unit of the `min_v` and `max_v` parameters, which can be celsius,
            fahrenheit or kelvin. Default is 'kelvin'. Only used when `mode` (see
            below) is 'minmax', thus ignored in the case of 'percentiles'.
        palette: str. Is `"embedded"` by default.
            The name of the color palette to use. See the `palettes` module to see
            which are supported in addition to "embedded".
                Alternatively, a list of 3-tuples can be passed in. Each
            3-tuple has 3 integers corresponding to the RGB components.
        mode: str, one of "minmax" | "percentiles". "minmax" is default.
            How to pre-process the data before rendering. Minmax is simply clips
            the data by a minimal and maximum value; percentiles does so
            by extracting a certain min/max percentile.
        histogram_equalized: bool
            Default is `False`. Enable this to enhance contrast and better see some
            objects. Be aware that this changes thermal pixels' apparant temperature,
            so only use for presentation purposes, not analytical ones. Implemented
            per the white paper 'Histogram equalization', but achieves slightly
            different results.

        Returns
        -------
        RGBArray
            A three dimensional array of integers between 0 and 255,
            representing an RGB render of the thermogram. Order is
            [H, W, C].
        """
        normalizer = {  # Functions defined below
            "minmax": norm.by_minmax,
            "percentiles": norm.by_percentiles,
        }

        # Validate parameters
        assert min_v is None or max_v is None or min_v < max_v
        assert not ((min_v is None or max_v is None) and unit == "percentiles")
        assert unit in ["kelvin", "celsius", "fahrenheit", "percentiles"]
        assert (
            palette in ["grayscale", "grayscale-inverted", "embedded"]
            or palette in palettes.palettes
        )
        assert mode in normalizer.keys()

        # In case the min / max values are None, find the right defaults
        emb_min_v, emb_max_v = self.embedded_range(unit)
        min_v = emb_min_v if min_v is None else min_v
        max_v = emb_max_v if max_v is None else max_v

        # Convert min/max values to kelvin if necessary
        if unit == "celsius":
            min_v = 273.15 + min_v
            max_v = 273.15 + max_v
        elif unit == "fahrenheit":
            min_v = 273.15 + (min_v - 32.0) / 1.8
            max_v = 273.15 + (max_v - 32.0) / 1.8

        # Render
        normalized = normalizer[mode](min_v, max_v, self.kelvin)
        if palette == "grayscale" or palette == "grayscale-inverted":
            # Strategy for grayscale is very different from when using a map
            num_grays = 256
            if histogram_equalized:
                normalized = norm.equalize_histogram(normalized, num_grays)

            rendered = (normalized * (num_grays - 1)).astype(np.uint8)
            outshape = rendered.shape + (3,)
            repeated = np.broadcast_to(rendered[..., None], outshape)
            repeated = np.clip(repeated, 0, (num_grays - 1))
            if palette == "grayscale-inverted":
                repeated = np.invert(repeated)
            return repeated
        elif palette == "embedded":
            if self.palette is not None:
                palette = self.palette.rgbs
            else:
                palette = "turbo"
                warnings.warn(f"No embedded palette detected, using {palette} instead")
        else:
            palette = palette

        return palettes.map_colors(normalized, palette, histogram_equalized)

    def render_pil(
        self,
        edge_emphasis: float = 0.0,
        color_mask: Optional[MaskArray] = None,
        msx_correction: bool = False,
        **kwargs,
    ) -> Image.Image:
        """Renders the thermogram, but returns a Pillow Image object.

        Via Pillow, this method can offer additional functionality,
        such edge edmphasis and MSX correction.

        See `render()` for documentation on the parameters and other details.
        The parameters listed below are exclusive for `render_pil()`.

        Parameters
        ----------
        edge_emphasis: float
            Default is `0.0`. The strength with which to enhance the render's
            edges, using the optical image as a base. The value must be between
            `0.0` and `1.0`, where the first means no emphasis. No edge emphasis
            happens in case there is no optical image present.
        color_mask: Optional[Array[bool, ..., ...]]
            Default is `None`. The region where the chosen palette will be applied
            and the edges enhanced. If `None` is given, the whole thermogram will
            be colored. The mask must be of type boolean, where `True` refers
            to the region of interest. It should be same shape as the thermogram.
        msx_correction: bool
            Default is `False`. Whether to apply MSX correction to images, which
            corrects alignment between the thermal and optical images when
            overlaying them. Disabling this only uses the metadata registered in
            the image to overlay the two; enabling additionally calculates
            offsets to overlay them better.

        Raises
        ------
        ValueError
            (1) if `color_mask` is not per the requirements; (2) if
            `msx_correction` is enabled and no optical image or
            picture-in-picture data is present. (2) if `edge_emphasis` is
            enabled in the same cases.

        Returns
        -------
        PIL.Image
            A pillow Image of the rendered thermogram.
        """
        valid_color_mask = color_mask is None or (
            isinstance(color_mask, np.ndarray)
            and color_mask.dtype == "bool"
            and color_mask.shape == self.kelvin.shape
        )
        if not valid_color_mask:
            msg = "Invalid color mask: It's either not an numpy array, not of type bool or the wrong shape"
            raise ValueError(msg)

        # If a color mask is given, render a grayscale background and put the
        # configured render on top of it
        render = self.render(**kwargs)
        bg_render_pil = fg_render_pil = Image.fromarray(render)
        if color_mask is not None:
            kwargs["palette"] = "grayscale"
            bg_render_pil = Image.fromarray(self.render(**kwargs))
            roi_mask = Image.fromarray(color_mask).convert("L")
            roi_mask = roi_mask.filter(ImageFilter.BoxBlur(2))
            bg_render_pil.paste(fg_render_pil, (0, 0), roi_mask)

        # If msx correction is enabled, calculate the shift to be applied
        shift_x, shift_y = 0, 0
        if msx_correction:
            if self.__optical is None:
                msg = "Can not calculate shift between thermal and optical image: No optical image present"
                raise ValueError(msg)
            if self.pip_info is None:
                msg = "Can not calculate shift between thermal and optical image: No picture-in-picture metadata present"
                raise ValueError(msg)
            shift_x, shift_y = msx.estimate_shift(
                render[:, :, 0], self.__optical, self.pip_info
            )

        # Render edges if requested
        if edge_emphasis > 0.0:
            if self.__optical is None:
                msg = "Can not determine edges: No optical image present"
                raise ValueError(msg)
            if self.pip_info is None:
                msg = "Can not determine edges: No picture-in-picture metadata present"
                raise ValueError(msg)

            bg_render_pil = edg.emphasize_edges(
                bg_render_pil,
                self.__optical,
                self.pip_info,
                edge_emphasis,
                color_mask,
                shift_x,
                shift_y,
            )

        bg_render_pil = bg_render_pil.convert("RGB")
        bg_render_pil.info["dy"] = shift_y
        bg_render_pil.info["dx"] = shift_x
        return bg_render_pil

    def picture_in_picture_pil(
        self,
        render: Optional[Image.Image] = None,
        render_opacity: float = 1.0,
        render_crop: bool = True,
        mask_mode: Literal["exclude", "grayscale"] = "exclude",
        edge_emphasis: float = 0.0,
        thermal_mask: Optional[MaskArray] = None,
        msx_correction: bool = False,
    ) -> Image.Image:
        """Render the thermal data on top of the optical data.

        See `msx_correction` and `edge_emphasis` for interesting ways to improve
        the render.

        Parameters
        ----------
        render: Pillow Image or None
            `None` by default, rendering from scratch if so. Otherwise places
            the given render on top of the RGB photo.
        render_opacity: float
            Default 1.0. Lower this value to make the thermal data (partially)
            transparant.
        render_crop: True
            Whether or not to crop the thermal data as specified in the
            Picture-in-Picture metadata. You probably want this.
        mask_mode: str, either "exclude" or "grayscale".
            Requires `thermal_mask` to be set. `"classical"` mask mode makes all
            the thermal pixels excluded by the mask transparent. "grayscale" does
            include these thermal pixels, but renders them grayscaled.
        edge_emphases: float
            0.0 by default, effectively disabling it. Raise it to see edges
            extracted from the optical data in the final render. Requires an RGB
            photo to have been captured alongside the thermal data. Range is 0 to 1.
            A good value is 0.275.
        thermal_mask: MaskArray or None,
            Defaults to `None`. If specified, only these thermal pixels are
            rendered on top of the RGB pixels. Also see the `mask_mode` parameter.
        msx_correction: bool
            Defaults to False. Whether or not to do additionally correction of
            the placement of thermogram on top of the optical data. Also affects
            edge emphasis' placement.

        Returns
        -------
        PIL.Image.Image
            The thermal data rendered on top of the optical data.
        """
        optical_pil = self.optical_pil
        pip_info = self.pip_info
        if optical_pil is None or pip_info is None:
            raise ValueError("Impossible operation due missing information")

        assert mask_mode in ["exclude", "grayscale"]

        ratio = pip_info.real_to_ir
        offset_x = pip_info.offset_x
        offset_y = pip_info.offset_y
        render_opacity = min(max(render_opacity, 0.0), 1.0)

        # Get the optical and render
        optical_pil = optical_pil.convert("RGBA")
        if render is None:
            # Render thermogram if none given
            render_pil = self.render_pil(
                edge_emphasis=edge_emphasis,
                color_mask=thermal_mask,
                msx_correction=msx_correction,
            )

            # Ensure there is an alpha channel and make transparent if classical mask mode
            render_arr = np.array(render_pil.convert("RGBA"))
            render_arr[:, :, 3] = round(255 * render_opacity)
            if (
                thermal_mask is not None
                and mask_mode == "exclude"
                and np.any(np.invert(thermal_mask))
            ):
                render_arr[np.invert(thermal_mask)] = 0

            render_pil = Image.fromarray(render_arr)
        else:
            render_pil = render.convert("RGBA")

        shift_x, shift_y = 0, 0
        if msx_correction:
            _optical = np.array(optical_pil)
            _render = np.array(render_pil)
            shift_x, shift_y = msx.estimate_shift(_render[:, :, 0], _optical, pip_info)

        # Resize the images according the picture-in-picture metadata
        ratio = optical_pil.size[0] / render_pil.size[0] / ratio
        if render_crop:
            render_pil = render_pil.crop(pip_info.crop_box)

        dst = (round(render_pil.size[0] * ratio), round(render_pil.size[1] * ratio))
        render_pil = render_pil.resize(dst)

        # Calculate placement and finish
        width, height = optical_pil.size
        origin_x, origin_y = width // 2, height // 2
        origin_x = origin_x - render_pil.size[0] // 2 + offset_x + shift_x
        origin_y = origin_y - render_pil.size[1] // 2 + offset_y + shift_y

        optical_pil.paste(render_pil, (origin_x, origin_y), render_pil)
        return optical_pil.convert("RGB")

    def adjust_metadata(
        self, in_place=False, **kwargs: Union[float, int]
    ) -> "FlyrThermogram":
        """Updates the physical metadata used to calculate the kelvin /
        celsius values based on the raw thermal data.

        This can be used to calculate kelvin/celsius values with different
        settings than the ones embedded in the thermogram itself during
        capture.

        This method does not check the given parameters. Wrong parameters
        names or values will be accepted without exceptions being raised.
        These exceptions will only occur when `kelvin` or `celsius` is
        accessed.

        Important: This does *not* adjust the metadata in the file itself;
        only the in-memory metadata used to calculate the temperatures
        returned by `kelvin` and `celsius` is updated. In other words, this
        method can *not* be used to modify or create a FLIR thermogram file
        with different camera settings.

        # Parameters
        in_place: boolean
            When False, a new `FlyrThermogram` object is returned after calling this
            method. When True, this object instance is modified in place and the
            object itself is returned.
        emissivity: float
        object_distance: float
        atmospheric_temperature: float
        ir_window_temperature: float
        ir_window_transmission: float
        reflected_apparent_temperature: float
        relative_humidity: float
        planck_r1: float
        planck_r2: float
        planck_b: float
        planck_f: int
        planck_o: int
        atmospheric_trans_alpha1: float
        atmospheric_trans_alpha2: float
        atmospheric_trans_beta1: float
        atmospheric_trans_beta2: float
        atmospheric_trans_x: float

        # Return
        FlyrThermogram
            When `in_place` is False, a new FlyrThermogram object with the updates
            settings. When `in_place` is True, the FlyrThermogram object on which this
            method is called.
        """
        msg = f"Parameter in_place incorrectly not of type bool but {type(in_place)}. Be sure to pass it first."
        assert isinstance(in_place, bool), msg
        if in_place:
            self.__metadata_adjustments.update(kwargs)
            return self

        metadata_adjustments = self.__metadata_adjustments.copy()
        metadata_adjustments.update(kwargs)

        return FlyrThermogram(
            self.__thermal.copy(),
            self.__metadata.copy(),
            None if self.__optical is None else self.__optical.copy(),
            palette=self.palette,
            picture_in_picture=self.pip_info,
            metadata_adjustments=metadata_adjustments,
            path=self.path,
        )

    def embedded_range(self, unit: Temperature) -> Tuple[float, float]:
        range_median = self.__metadata["raw_value_median"]
        range_half = self.__metadata["raw_value_range"] / 2

        raw_vals = np.array([[range_median - range_half, range_median + range_half]])
        min_v, max_v = self.__raw_to_kelvin_with_metadata(raw_vals, orig=True).squeeze()
        if unit == "celsius":
            min_v = min_v - 273.15
            max_v = max_v - 273.15
        elif unit == "fahrenheit":
            min_v = (min_v - 273.15) * 1.8 + 32.00
            max_v = (max_v - 273.15) * 1.8 + 32.00

        return (min_v, max_v)

    def __raw_to_kelvin_with_metadata(
        self, thermal, orig: bool = False
    ) -> Generic2DArray[np.float64]:
        metadata = self.__metadata.copy()
        if not orig:
            metadata.update(self.__metadata_adjustments)

        return FlyrThermogram.__raw_to_kelvin(
            thermal,
            metadata["emissivity"],
            metadata["object_distance"],
            metadata["atmospheric_temperature"],
            metadata["ir_window_temperature"],
            metadata["ir_window_transmission"],
            metadata["reflected_apparent_temperature"],
            metadata["relative_humidity"],
            metadata["planck_r1"],
            metadata["planck_r2"],
            metadata["planck_b"],
            metadata["planck_f"],
            metadata["planck_o"],
            metadata["atmospheric_trans_alpha1"],
            metadata["atmospheric_trans_alpha2"],
            metadata["atmospheric_trans_beta1"],
            metadata["atmospheric_trans_beta2"],
            metadata["atmospheric_trans_x"],
        )

    @staticmethod
    def __raw_to_kelvin(
        thermal,
        emissivity,
        object_distance,
        atmospheric_temperature,
        ir_window_temperature,
        ir_window_transmission,
        reflected_apparent_temperature,
        relative_humidity,
        planck_r1,
        planck_r2,
        planck_b,
        planck_f,
        planck_o,
        atmospheric_trans_alpha1,
        atmospheric_trans_alpha2,
        atmospheric_trans_beta1,
        atmospheric_trans_beta2,
        atmospheric_trans_x,
    ) -> Generic2DArray[np.float64]:
        """Use the details camera info metadata to translate the raw
        temperatures to °Kelvin.

        Parameters
        ----------
        thermal: RawArray
            The thermal data to convert from raw values to kelvin
        emissivity : float
        object_distance : float
            Unit is meters
        atmospheric_temperature : float
            Unit is Kelvin
        ir_window_temperature : float
            Unit is Kelvin
        ir_window_transmission : float
            Unit is Kelvin
        reflected_apparent_temperature : float
            Unit is Kelvin
        relative_humidity : float
            Value in 0 and 1
        planck_r1 : float
        planck_r2 : float
        planck_b : float
        planck_f : float
        planck_o : int
        atmospheric_trans_alpha1 : float
        atmospheric_trans_alpha2 : float
        atmospheric_trans_beta1 : float
        atmospheric_trans_beta2 : float
        atmospheric_trans_x : float

        Returns
        -------
        Generic2DArray[np.float64]
            An array of float64 values in kelvin.
        """
        # Transmission through window (calibrated)
        emiss_wind = 1 - ir_window_transmission
        refl_wind = 0

        # Transmission through the air
        water = relative_humidity * exp(
            1.5587
            + 0.06939 * (atmospheric_temperature - 273.15)
            - 0.00027816 * (atmospheric_temperature - 273.17) ** 2
            + 0.00000068455 * (atmospheric_temperature - 273.15) ** 3
        )

        def calc_atmos(alpha, beta):
            term1 = -sqrt(object_distance / 2)
            term2 = alpha + beta * sqrt(water)
            return exp(term1 * term2)

        atmos1 = calc_atmos(atmospheric_trans_alpha1, atmospheric_trans_beta1)
        atmos2 = calc_atmos(atmospheric_trans_alpha2, atmospheric_trans_beta2)
        tau1 = atmospheric_trans_x * atmos1 + (1 - atmospheric_trans_x) * atmos2
        tau2 = atmospheric_trans_x * atmos1 + (1 - atmospheric_trans_x) * atmos2

        # Radiance from the environment
        def plancked(t):
            planck_tmp = planck_r2 * (exp(planck_b / t) - planck_f)
            return planck_r1 / planck_tmp - planck_o

        raw_refl1 = plancked(reflected_apparent_temperature)
        raw_refl1_attn = (1 - emissivity) / emissivity * raw_refl1

        raw_atm1 = plancked(atmospheric_temperature)
        raw_atm1_attn = (1 - tau1) / emissivity / tau1 * raw_atm1

        term3 = emissivity * tau1 * ir_window_transmission
        raw_wind = plancked(ir_window_temperature)
        raw_wind_attn = emiss_wind / term3 * raw_wind

        raw_refl2 = plancked(reflected_apparent_temperature)
        raw_refl2_attn = refl_wind / term3 * raw_refl2

        raw_atm2 = plancked(atmospheric_temperature)
        raw_atm2_attn = (1 - tau2) / term3 / tau2 * raw_atm2

        subtraction = fsum(
            [
                raw_atm1_attn,
                raw_atm2_attn,
                raw_wind_attn,
                raw_refl1_attn,
                raw_refl2_attn,
            ]
        )

        raw_obj = thermal.astype(np.float64)
        raw_obj /= emissivity * tau1 * ir_window_transmission * tau2
        raw_obj -= subtraction

        # Temperature from radiance
        raw_obj += planck_o
        raw_obj *= planck_r2
        planck_term = planck_r1 / raw_obj + planck_f
        return planck_b / np.log(planck_term)
