from pathlib import Path
from typing import Union

import numpy as np
try:
    import pandas as pd
except ImportError:
    from .pymesh.stubs import pandas as pd

from .helpers.tpc_reader import TPCReader
from .info import INFO
from .._pytuflow_types import PathLike, TimeLike
from .helpers.fm_res_driver import FMResultDriver
from .helpers.lp_1d_fm import LP1DFM
from .._fm import GXY
from .._fm import FMDAT
from ..util import pytuflow_logging
from ..results import ResultTypeError


logger = pytuflow_logging.get_logger()


class FMTS(INFO):
    """Class for handling 1D Flood Modeller time series outputs.

    The accepted result formats are:

    * :code:`.zzn` (requires the accompanying :code:`.zzl` file)
    * :code:`.csv` exported from the Flood Modeller GUI.
    * :code:`.csv` exported using the Flood Modeller API Python library

    Optional DAT and GXY files can be provided to add connectivity and spatial information. The DAT file is preferenced
    over GXY for connectivity information as channel direction around JUNCTION units can be ambiguous in the GXY.
    Spatial information contained in the DAT file is never used and a GXY file is required for spatial information.

    Parameters
    ----------
    fpath : PathLike
        The path to the flood modeller result file(s). Multiple files can be passed if not using the :code:`.zzn`
        result format. Multiple files are required to be for different result types for the same event, and not
        for multiple events.
    dat : PathLike, optional
        Path to the DAT file. Required for connectivity information (i.e. required for :meth:`section` plotting).
    gxy : PathLike, optional
        Path to the GXY file. Required for spatial coordinates.

    Raises
    ------
    ResultTypeError
        Raises :class:`pytuflow.results.ResultTypeError` if the file does not look like a time series ``FMTS`` file, or
        if the result file(s) do not contain the expected data e.g. if multiple files are provided, but they belong
        to different models or different events.

    Examples
    --------
    Load results without any reference to the DAT or GXY files - this will not support section plotting and won't
    contain spatial information:

    >>> from pytuflow import FMTS
    >>> res = FMTS('path/to/result.zzn')

    Load results with a DAT file so that connectivity information is available for section plotting:

    >>> res = FMTS('path/to/result.zzn', dat='path/to/result.dat')

    Load results with a GXY so spatial information is available - a GXY can also be used for connectivity
    without a DAT file, however less contextual information is contained in the GXY and channel directions around
    JUNCTION nodes can be ambiguous. The DAT file is recommended, and is required for plotting :code:`pipes`
    in the section plot:

    >>> res = FMTS('path/to/result.zzn', dat='path/to/result.dat', gxy='path/to/result.gxy')

    Return all node IDs - Only nodes that contain results will be return by default and will be returned with just
    the node name. To return all nodes with unique IDs (:code:`TYPE_SUBTYPE_NAME`), use the :code:`filter_by` parameter:

    >>> res.ids()
    ['FC01.36', 'FC01.35', 'FC01.351cu',... 'FC02', 'ds2_S', 'FC01']
    >>> res.ids('node')
    ['QTBDY__FC01', 'JUNCTION_OPEN_FC01', 'RIVER_SECTION_FC01.40',... 'JUNCTION_OPEN_ds2', 'SPILL__ds2_S']

    Return available result types:

    >>> res.data_types()
    ['flow', 'water level', 'froude', 'velocity', 'mode', 'state']

    Return the :code:`water level` time series for :code:`FC04368`:

    >>> res.time_series('FC01.35', 'stage')
    time      node/stage/FC01.35
    0.000000           44.803001
    0.083333           45.329525
    0.166667           45.717449
    0.250000           45.766171
    0.333333           45.776295
    ...                      ...
    2.666667           45.778118
    2.750000           45.778111
    2.833333           45.778091
    2.916667           45.778099
    3.000000           45.778137

    Return the maximum :code:`water level` along a section between :code:`FC01.31` and :code:`FC01.25`:

    >>> res.section(['FC01.31', 'FC01.25'], 'max stage', 0)
        branch_id channel      node   offset   max stage
    0           0      36   FC01.31    0.000   43.308998
    7           0      36   FC01.30   30.758   43.060001
    1           0      37   FC01.30   30.758   43.060001
    8           0      37   FC01.29   62.745   42.870998
    2           0      38   FC01.29   62.745   42.870998
    9           0      38  FC01.28B  112.141   42.409000
    3           0      39  FC01.28B  112.141   42.409000
    10          0      39   FC01.28  146.031   42.105000
    4           0      40   FC01.28  146.031   42.105000
    11          0      40   FC01.27  187.033   41.692001
    5           0      41   FC01.27  187.033   41.692001
    12          0      41   FC01.26  200.826   41.493999
    6           0      42   FC01.26  200.826   41.493999
    13          0      42   FC01.25  233.658   41.021000
    """

    def __init__(self, fpath: Union[PathLike, list[PathLike]], dat: PathLike = None, gxy: PathLike = None):
        # private
        self._fpaths = fpath if isinstance(fpath, list) else [fpath]
        self._fpaths = [Path(f) for f in self._fpaths if f]
        self._support_section_plotting = False

        #: list[FM_ResultDriver]: Storage for the result drivers.
        self._storage = []

        #: Path: Path to the DAT file if one was provided
        self.dat_fpath = Path(dat) if dat is not None else None

        #: Path: Path to the GXY file if one was provided
        self.gxy_fpath = Path(gxy) if gxy is not None else None

        #: DAT: DAT object.
        self._dat = None

        #: GXY: GXY object.
        self._gxy = None

        for f in self._fpaths:
            if not f.exists():
                raise FileNotFoundError(f'File not found: {f}')
            if not self._looks_like_this(f):
                raise ResultTypeError(f'File does not look like a Flood Modeller time series result: {f}')
            if self._looks_empty(f):
                raise EOFError(f'File is empty or incomplete: {f}')

        super().__init__(self._fpaths[0])

        self._section_geom = 'node'

    @staticmethod
    def _looks_like_this(fpath: Path) -> bool:
        # docstring inherited
        driver = FMResultDriver(fpath)
        return driver.driver_name != ''

    @staticmethod
    def _looks_empty(fpath: PathLike) -> bool:
        # docstring inherited
        driver = FMResultDriver(fpath)
        return driver.df is None or driver.df.empty

    def times(self, filter_by: str = None, fmt: str = 'relative') -> list[TimeLike]:
        # docstring inherited
        return super().times(filter_by, fmt)

    def ids(self, filter_by: str = None) -> list[str]:
        """Returns all the available IDs for the given filter. By default, only IDs that contain results are returned.
        The returned IDs are also returned as just their name e.g. :code:`FC01.1_R` rather than the full ID
        e.g. :code:`CONDUIT_CIRCULAR_FC01.1_R`.

        The ``filter_by`` argument can be used to add a filter to the returned IDs. Available
        filters for the ``FMTS`` class are:

        * :code:`None`: default - returns all :code:`timeseries` IDs (i.e. IDs that contain results).
        * :code:`1d`: same as :code:`None` as class only contains 1D data
        * :code:`node` - returns all nodes/units regardless of whether they contain results. The full ID is also
          returned in case there are duplicate names. Only applicable if a :code:`DAT` or :code:`GXY` file is provided.
        * :code:`channel` - returns all channel IDs. Channel IDs are not returned by when using :code:`None` as
          they don't contain any result data. Only applicable if a :code:`DAT` or :code:`GXY` file is provided.
        * :code:`timeseries`: returns only IDs that have time series data.
        * :code:`section`: returns only IDs that have section data (i.e. long plot data).
        * :code:`[data_type]`: returns only IDs for the given data type. Shorthand data type names can be used.

        Parameters
        ----------
        filter_by : str, optional
            The string to filter the IDs by.

        Returns
        -------
        list[str]
            The available IDs.

        Examples
        --------
        >>> res = FMTS('path/to/result.zzn', dat='path/to/result.dat', gxy='path/to/result.gxy')
        >>> res.ids()
        ['FC01.36', 'FC01.35', 'FC01.351cu',... 'FC02', 'ds2_S', 'FC01']
        >>> res.ids('node')
        ['QTBDY__FC01', 'JUNCTION_OPEN_FC01', 'RIVER_SECTION_FC01.40',... 'JUNCTION_OPEN_ds2', 'SPILL__ds2_S']
        """
        self._load()
        if filter_by and filter_by.lower() == 'channel':
            return self._channel_info.index.tolist()
        if filter_by and filter_by.lower() == 'node':
            return self._node_info.index.tolist()
        return super().ids(filter_by)

    def data_types(self, filter_by: str = None) -> list[str]:
        """Returns all the available data types (result types) for the output given the filter.

        The ``filter_by`` is an optional input that can be used to filter the return further. Available
        filters for the ``FMTS`` class are:

        Domain filters:

        * :code:`1d`
        * :code:`2d` (or :code:`po`)
        * :code:`rl` (or :code:`0d`)

        Geometry filters:

        * :code:`node`
        * :code:`channel`
        * :code:`point` - (for 2d and rl domains only - use :code:`node` for 1d domain)
        * :code:`line` - (for 2d and rl domains only - use :code:`channel` for 1d domain)
        * :code:`polygon` (or :code:`region`)

        Location filters:

        * :code:`[location]`: The location to filter the data_types by.

        Combine filters:

        * ``[filter1]/[filter2]/...``: (use ``/`` to delim).

        Parameters
        ----------
        filter_by : str, optional
            The string to filter the data types by.

        Returns
        -------
        list[str]
            The available data types.

        Examples
        --------
        """
        self._load()
        dat_types = super().data_types(filter_by)
        if filter_by and 'section' in filter_by:
            if not self._support_section_plotting:
                return []
            if 'pits' in dat_types:
                dat_types.remove('pits')
            if 'bed level' in dat_types and not self._dat:
                dat_types.remove('bed level')
            if 'pipes' in dat_types and not self._dat:
                dat_types.remove('pipes')
        return dat_types

    def maximum(self, locations: str | list[str] | None, data_types: str | list[str] | None,
                time_fmt: str = 'relative') -> pd.DataFrame:
        """Returns a DataFrame containing the maximum values for the given data types. The returned DataFrame
        will include time of maximum results as well.

        It's possible to pass in a well known shorthand for the data type e.g. :code:`q` for :code:`flow`.

        The location can also be a filter string, e.g. :code:`channel` to extract the maximum values for all
        channels. The following filters are available for the ``FMTS`` class:

        * :code:`None`: returns all maximum values
        * :code:`1d`: returns all maximum values (same as passing in None for locations)
        * :code:`node`

        The returned DataFrame will have an index column corresponding to the location IDs, and the columns
        will be in the format :code:`obj/data_type/[max|tmax]`,
        e.g. :code:`node/flow/max`, :code:`node/flow/tmax`

        Parameters
        ----------
        locations : str | list[str]
            The location to extract the maximum values for. :code:`None` will return all locations for the
            given data_types.
        data_types : str | list[str]
            The data types to extract the maximum values for. :code:`None` will return all data types for the
            given locations.
        time_fmt : str, optional
            The format for the time of max result. Options are :code:`relative` or :code:`absolute`

        Returns
        -------
        pd.DataFrame
            The maximum, and time of maximum values

        Examples
        --------
        Extracting the maximum flow for a given channel:

        >>> res = FMTS('path/to/result.zzn', dat='path/to/result.dat')
        >>> res.maximum('ds1', 'flow')
             node/flow/max  node/flow/tmax
        ds2       22.05114        1.583333

        Extracting all the maximum results for a given channel:

        >>> res.maximum(['ds1'], None)
             node/flow/max  node/flow/tmax  ...  node/state/max  node/state/tmax
        ds2       22.05114        1.583333  ...             0.0              0.0

        Extracting the maximum flow for all channels:

        >>> res.maximum(None, 'flow')
                    node/flow/max  node/flow/tmax
        FC01.36          0.840000        0.000000
        FC01.35         10.011658        0.333333
        FC01.351cu       3.337249        0.333333
        FC01.351cd       3.337087        0.333333
        FC01.351co       3.337087        0.333333
        ...                   ...             ...
        FC01.2_Rd5       4.428591        1.500000
        FC01.2_Ro5       4.428591        1.500000
        FC02             0.080000        0.000000
        ds2_S           22.051140        1.583333
        FC01             0.920000        0.000000
        """
        return super().maximum(locations, data_types, time_fmt)

    def time_series(self, locations: str | list[str] | None, data_types: str | list[str] | None,
                    time_fmt: str = 'relative', *args, **kwargs) -> pd.DataFrame:
        """Returns a time-series DataFrame for the given location(s) and data type(s).

        It's possible to pass in a well known shorthand for the data type e.g. :code:`q` for :code:`flow`.

        The location can also be a filter string, e.g. :code:`channel` to extract the time-series values for all
        channels. The following filters are available for the ``FMTS`` class:

        * :code:`None`: returns all locations
        * :code:`1d`: returns all locations (same as passing in None for locations)
        * :code:`node`

        The returned column names will be in the format :code:`obj/data_type/location`
        e.g. :code:`channel/flow/FC01.1_R`. The :code:`data_type` name in the column heading will be identical to the
        data type  name passed into the function e.g. if :code:`h` is used instead of :code:`water level`, then the
        return will be :code:`node/h/FC01.1_R.1`.

        Parameters
        ----------
        locations : str | list[str]
            The location to extract the time series data for. If :code:`None` is passed in, all locations will be
            returned for the given data_types.
        data_types : str | list[str]
            The data type to extract the time series data for. If :code:`None` is passed in, all data types
            will be returned for the given locations.
        time_fmt : str, optional
            The format for the time column. Options are :code:`relative` or :code:`absolute`.

        Returns
        -------
        pd.DataFrame
            The time series data.

        Examples
        --------
        Extracting flow for a given channel.

        >>> res = FMTS('path/to/result.zzn', dat='path/to/result.dat')
        >>> res.time_series('ds2', 'q')
        time      node/q/ds1
        0.000000    0.920000
        0.083333    0.910979
        0.166667    0.886582
        0.250000    0.929299
        ...              ...
        2.666667   14.194231
        2.750000   13.511795
        2.833333   12.955027
        2.916667   12.494930
        3.000000   12.135681

        Extracting all data types for a given location

        >>> res.time_series('ds1', None)
                  node/flow/ds1  node/water level/ds1  ...  node/mode/ds1  node/state/ds1
        time                                           ...
        0.000000       0.920000             36.493000  ...            0.0             0.0
        0.083333       0.910979             36.525406  ...            0.0             0.0
        0.166667       0.886582             36.516323  ...            0.0             0.0
        0.250000       0.929299             36.524315  ...            0.0             0.0
        0.333333       2.275538             36.745884  ...            0.0             0.0
        ...                 ...                   ...  ...            ...             ...
        2.666667      14.194231             37.933880  ...            0.0             0.0
        2.750000      13.511795             37.898212  ...            0.0             0.0
        2.833333      12.955027             37.869987  ...            0.0             0.0
        2.916667      12.494930             37.848312  ...            0.0             0.0
        3.000000      12.135681             37.833549  ...            0.0             0.0

        Extracting all flow results

        >>> res.time_series(None, 'flow')
        time      node/flow/FC01.36  node/flow/FC01.35  ...  node/flow/ds2_S  node/flow/FC01
        0.000000               0.84           0.840000  ...         0.920000            0.92
        0.083333               0.00           5.730267  ...         0.917122            0.00
        0.166667               0.00           9.591267  ...         0.890924            0.00
        0.250000               0.00           9.981448  ...         0.904744            0.00
        0.333333               0.00          10.011658  ...         1.959577            0.00
        ...                     ...                ...  ...              ...             ...
        2.666667               0.00          10.006260  ...        14.305518            0.00
        2.750000               0.00          10.006241  ...        13.612213            0.00
        2.833333               0.00          10.006139  ...        13.029665            0.00
        2.916667               0.00          10.006124  ...        12.559101            0.00
        3.000000               0.00          10.006373  ...        12.182993            0.00
        """
        return super().time_series(locations, data_types, time_fmt)

    def section(self, locations: Union[str, list[str]], data_types: Union[str, list[str]],
                time: TimeLike, *args, **kwargs) -> pd.DataFrame:
        """Returns a long plot for the given location and data types at the given time. If one location is given,
        the long plot will connect the given location down to the outlet. If 2 locations are given, then the
        long plot will connect the two locations (they must be connectable).

        The locations should correspond to node names (not the full ID). The first location should be upstream
        of the second location as the function will assume the first node is the most upstream point and the second
        node is the most downstream point and accordingly (if the nodes are the other way round then the start and
        end channels will not be exactly correct).

        The order of the locations in the :code:`location` parameter does not matter as both directions are
        checked, however it will be faster to include the upstream location first as this will be the first connection
        checked.

        The returned DataFrame will have the following columns:

        * :code:`branch_id`: The branch ID. If more than 2 pipes are provided, or the channels diverge at an intersection,
          then multiple branches will be returned. The same channel could be in multiple branches. The branch id
          starts at zero for the first branch, and increments by one for each additional branch.
        * :code:`channel`: The channel ID.
        * :code:`node`: The node ID.
        * :code:`offset`: The offset along the long plot
        * :code:`[data_types]`: The data types requested.

        Parameters
        ----------
        locations : str | list[str]
            The location to extract the section data for. Unlike other plotting methods, the location cannot be None.
        data_types : str | list[str]
            The data type to extract the section data for. If None is passed in, all node data types will be returned.
        time : TimeLike
            The time to extract the section data for.

        Returns
        -------
        pd.DataFrame
            The section data.

        Raises
        ------
        ResultTypeError
            Raised if no valid :code:`locations` are provided or if :code:`data_types` is not :code:`None`
            but the provided :code:`data_types` are all invalid. A value error is also raised if more than one location
            is provided and the locations are not connected.

        Examples
        --------
        Extracting a long plot from a given channel :code:`ds1` to the outlet at :code:`1.0` hours:

        >>> res = FMTS('path/to/result.zzn', dat='path/to/result.dat')
        >>> res.section('ds1', ['bed', 'level', 'max level'], 1.)
            branch_id  channel       node  offset     bed    level  max level
        0           0      ds1      ds1.1     0.0  35.950  38.7880    39.0671
        6           0      ds1      ds1.2    30.2  35.900  38.6880    38.9963
        1           0      ds2      ds1.2    30.2  35.900  38.6880    38.9963
        7           0      ds2      ds2.2    88.8  35.320  38.1795    38.5785
        2           0      ds3      ds2.2    88.8  35.320  38.1795    38.5785
        8           0      ds3      ds3.2   190.0  34.292  37.1793    37.4158
        3           0      ds4      ds3.2   190.0  34.292  37.1793    37.4158
        9           0      ds4      ds4.2   301.6  33.189  35.6358    35.9533
        4           0      ds5      ds4.2   301.6  33.189  35.6358    35.9533
        10          0      ds5      ds5.2   492.7  31.260  33.9942    34.3672
        5           0  ds_weir      ds5.2   492.7  32.580  33.9942    34.3672
        11          0  ds_weir  ds_weir.2   508.9  32.580  32.9532    33.4118

        Extracting a long plot between :code:`ds1` and :code:`ds4` at :code:`1.0` hours:

        >>> res.section(['ds1', 'ds4'], ['bed', 'level', 'max level'], 1.)
           branch_id channel   node  offset     bed    level  max level
        0          0     ds1  ds1.1     0.0  35.950  38.7880    39.0671
        4          0     ds1  ds1.2    30.2  35.900  38.6880    38.9963
        1          0     ds2  ds1.2    30.2  35.900  38.6880    38.9963
        5          0     ds2  ds2.2    88.8  35.320  38.1795    38.5785
        2          0     ds3  ds2.2    88.8  35.320  38.1795    38.5785
        6          0     ds3  ds3.2   190.0  34.292  37.1793    37.4158
        3          0     ds4  ds3.2   190.0  34.292  37.1793    37.4158
        7          0     ds4  ds4.2   301.6  33.189  35.6358    35.9533
        """
        if not self._support_section_plotting:
            raise ResultTypeError('A DAT or GXY file is required for section plotting')

        self._load()

        locations = [locations] if not isinstance(locations, list) else locations

        # convert ids to uids
        locs = [self._id_to_uid(x) for x in locations]
        for i, x in enumerate(reversed(locs)):
            j = len(locations) - 1 - i
            if x is None:
                logger.warning(f'FMTS.section(): Could not find a valid UID for {locations[j]} - removing.')
                locs.pop(j)
        if not locs:
            raise ValueError('No valid locations provided.')

        return super().section(locs, data_types, time, filter_by='node', **kwargs)

    def curtain(self, locations: Union[str, list[str]], data_types: Union[str, list[str]],
                time: TimeLike) -> pd.DataFrame:
        """Not supported for ``FMTS`` results. Raises a :code:`NotImplementedError`."""
        return super().curtain(locations, data_types, time)

    def profile(self, locations: Union[str, list[str]], data_types: Union[str, list[str]],
                time: TimeLike, **kwargs) -> pd.DataFrame:
        """Not supported for ``FMTS`` results. Raises a :code:`NotImplementedError`."""
        return super().profile(locations, data_types, time)

    def _connectivity(self, ids: Union[str, list[str]]) -> pd.DataFrame:
        # docstring inherited
        lp = LP1DFM(ids, self._node_info, self._channel_info)
        if self._lp is not None and lp == self._lp:
            return self._lp.df

        lp.connectivity()
        self._lp = lp
        return self._lp.df

    def _id_to_uid(self, id_: str) -> str | None:
        """Converts a unit ID to its UID. Only searches through units that
        contain results. If multiple units are found, it will first preference units that have bed level information
        (requires that the class was initialised with a DAT file) as this will preference units such as
        rivers, conduits, structures etc. over units such as junctions. If multiple are still found, the first instance
        is returned. If no unit is found, :code:`None` is returned

        Parameters
        ----------
        id_: str
            The unit ID to convert.

        Returns
        -------
        str:
            The uid of the unit.
        """
        # check if id is already a uid
        if id_.lower() in self._node_info.index.str.lower():
            return id_
        df = self._node_info[self._node_info['has_results']].copy()
        df['id2'] = df.index.str.split('_', n=2).str[-1].str.lower()
        df = df[df['id2'] == id_.lower()]
        if df.empty:
            return None
        # if multiple are returned, a unit with a bed elevation is less likely to be a junction
        df1 = df[~np.isnan(df['bed_level'])]
        if df1.empty:
            return df.index[0]
        return df1.index[0]

    def _init_tpc_reader(self) -> TPCReader:
        pass

    def _initial_load(self):
        if self._loaded:
            return

        # initialise the storage/drivers for each result file
        ids, res_types = None, None
        for fpath in self._fpaths:
            driver = FMResultDriver(fpath)
            if driver.driver_name == 'zzn' and len(self._fpaths) > 1:
                raise ResultTypeError('Cannot load multiple results and one of them is a ZZN file')

            if ids is None:
                ids = driver.ids
            else:
                if ids != driver.ids:
                    raise ResultTypeError('Result IDs do not match')

            if res_types is None:
                res_types = driver.result_types
            else:
                if np.intersect1d(res_types, driver.result_types).size:
                    raise ResultTypeError('Duplicate result types found in the result files')

            self._storage.append(driver)

        self.name = self._storage[0].display_name
        for driver in self._storage:
            driver.reference_time = self.reference_time

        # Initialise DAT
        if self.dat_fpath is not None:
            self._dat = FMDAT(self.dat_fpath)
            self._support_section_plotting = True

        # Initialise GXY
        if self.gxy_fpath is not None:
            self._gxy = GXY(self.gxy_fpath)
            self._support_section_plotting = True

        # load time series
        for driver in self._storage:
            for res_type in driver.result_types:
                stnd = self._get_standard_data_type_name(res_type)
                self._nd_res_types.append(stnd)  # all results are stored on nodes in flood modeller results
                try:
                    df = driver.df.loc[:,driver.df.columns.str.contains(f'^{res_type}::')]
                except AttributeError:
                    df = driver.df.loc[:, driver.df.columns.str.contains(f'{res_type}::', regex=False)]
                df.columns = [x.split('::')[1] for x in df.columns]
                self._time_series_data[stnd] = df

        # load max data
        self._load_maximums()

        # load node/channel information
        self._load_nodes()
        self._load_channels()
        self._load_1d_info()

        self.node_count = self._node_info.shape[0]
        self.channel_count = self._channel_info.shape[0]

        self._loaded = True

    def _load(self) -> None:
        return

    def _load_nodes(self):
        """Loads FM Nodes into node_info pd.DataFrame."""
        d = {'id': [], 'bed_level': [], 'top_level': [], 'nchannel': [], 'channels': [], 'type': [], 'has_results': [],
             'name': []}
        if self._dat:
            for unit in self._dat.units:
                d['id'].append(unit.uid)
                d['bed_level'].append(unit.bed_level)
                d['top_level'].append(np.nan)
                d['nchannel'].append(len(unit.ups_units) + len(unit.dns_units))
                if d['nchannel'][-1] == 1:
                    d['channels'].append(str(unit.ups_link_ids[0]) if unit.ups_link_ids else str(unit.dns_link_ids[0]))
                else:
                    d['channels'].append([str(x) for x in unit.ups_link_ids] + [str(x) for x in unit.dns_link_ids])
                d['type'].append(f'{unit.type}_{unit.sub_type}')
                d['has_results'].append(unit.id in self._storage[0].ids)
                d['name'].append(unit.id)
        elif self._gxy:
            for unit in self._gxy.nodes:
                d['id'].append(unit.uid)
                d['bed_level'].append(np.nan)
                d['top_level'].append(np.nan)
                ups_links = self._gxy.link_df[self._gxy.link_df['dns_node'] == unit.uid]
                dns_links = self._gxy.link_df[self._gxy.link_df['ups_node'] == unit.uid]
                d['nchannel'].append(len(ups_links) + len(dns_links))
                if d['nchannel'][-1] == 1:
                    d['channels'].append(str(ups_links.index.tolist()[0]) if not ups_links.empty else str(dns_links.index.tolist()[0]))
                else:
                    d['channels'].append([str(x) for x in ups_links.index.tolist()] + [str(x) for x in dns_links.index.tolist()])
                d['type'].append(unit.type)
                d['has_results'].append(unit.id in self._storage[0].ids)
                d['name'].append(unit.id)
        else:
            d['id'] = self._storage[0].ids
            d['bed_level'] = [np.nan for _ in d['id']]
            d['top_level'] = [np.nan for _ in d['id']]
            d['nchannel'] = [0 for _ in d['id']]
            d['channels'] = [[] for _ in d['id']]
            d['type'] = ['' for _ in d['id']]
            # noinspection PyTypeChecker
            d['has_results'] = True
            d['name'] = d['id']

        self._node_info = pd.DataFrame(d)
        self._node_info.set_index('id', inplace=True)

    def _load_channels(self):
        """LoadsFM Channels into channel_info pd.DataFrame."""
        d = {'id': [], 'us_node': [], 'ds_node': [], 'us_chan': [], 'ds_chan': [], 'ispipe': [], 'length': [],
                'us_invert': [], 'ds_invert': [], 'lbus_obvert': [], 'rbus_obvert': [], 'lbds_obvert': [],
                'rbds_obvert': []}
        if self._dat:
            for link in self._dat.links:
                d['id'].append(str(link.id))
                if link.ups_unit:
                    d['us_node'].append(link.ups_unit.uid)
                    if link.ups_unit.ups_link_ids:
                        d['us_chan'].append(str(link.ups_unit.ups_link_ids[0]))
                    else:
                        d['us_chan'].append('')
                else:
                    d['us_node'].append('')
                if link.dns_unit:
                    d['ds_node'].append(link.dns_unit.uid)
                    if link.dns_unit.dns_link_ids:
                        d['ds_chan'].append(str(link.dns_unit.dns_link_ids[0]))
                    else:
                        d['ds_chan'].append('')
                us_obv = np.nan
                ds_obv = np.nan
                if link.ups_unit.unit_type_name() == 'CONDUIT' and link.ups_unit.dx > 0:
                    d['ispipe'].append(True)
                    if link.ups_unit.sub_type.upper() == 'CIRCULAR':
                        us_obv = link.ups_unit.bed_level + link.ups_unit.dia
                        ds_obv = link.dns_unit.bed_level + link.dns_unit.dia
                    elif link.ups_unit.sub_type.upper() == 'RECTANGULAR':
                        us_obv = link.ups_unit.bed_level + link.ups_unit.height
                        ds_obv = link.dns_unit.bed_level + link.dns_unit.height
                    elif link.ups_unit.sub_type.upper() in ['ASYMMETRIC', 'SECTION']:
                        us_obv = link.ups_unit.bed_level + link.ups_unit.section.y.max()
                        ds_obv = link.dns_unit.bed_level + link.dns_unit.section.y.max()
                    elif link.ups_unit.sub_type.upper() in ['FULL', 'FULLARCH']:
                        us_obv = link.ups_unit.bed_level + link.ups_unit.archyt
                        ds_obv = link.dns_unit.bed_level + link.dns_unit.archyt
                    elif link.ups_unit.sub_type.upper() in ['SPRUNG', 'SPRUNGARCH']:
                        us_obv = link.ups_unit.bed_level + link.ups_unit.sprhyt + link.ups_unit.archyt
                        ds_obv = link.dns_unit.bed_level + link.dns_unit.sprhyt + link.dns_unit.archyt
                else:
                    d['ispipe'].append(False)
                length = 0.
                if hasattr(link.ups_unit, 'dx') and not np.isnan(link.ups_unit.dx):
                    length = link.ups_unit.dx
                d['length'].append(length)
                d['us_invert'].append(link.ups_unit.bed_level)
                d['ds_invert'].append(link.dns_unit.bed_level)
                d['lbus_obvert'].append(us_obv)
                d['rbus_obvert'].append(us_obv)
                d['lbds_obvert'].append(ds_obv)
                d['rbds_obvert'].append(ds_obv)
        elif self._gxy:
            for index, row in self._gxy.link_df.iterrows():
                d['id'].append(str(index))
                us_node = row['ups_node']
                ds_node = row['dns_node']
                d['us_node'].append(us_node)
                d['ds_node'].append(ds_node)
                ups_links = self._gxy.link_df[self._gxy.link_df['dns_node'] == us_node]
                if not ups_links.empty:
                    d['us_chan'].append(str(ups_links.index.tolist()[0]))
                else:
                    d['us_chan'].append('')
                dns_links = self._gxy.link_df[self._gxy.link_df['ups_node'] == ds_node]
                if not dns_links.empty:
                    d['ds_chan'].append(str(dns_links.index.tolist()[0]))
                else:
                    d['ds_chan'].append('')
                d['ispipe'].append(False)
                us_xy = self._gxy.node_df.loc[us_node, ['x', 'y']].to_numpy()
                ds_xy = self._gxy.node_df.loc[ds_node, ['x', 'y']].to_numpy()
                length = np.linalg.norm(ds_xy - us_xy)
                d['length'].append(length)
                d['us_invert'].append(np.nan)
                d['ds_invert'].append(np.nan)
                d['lbus_obvert'].append(np.nan)
                d['rbus_obvert'].append(np.nan)
                d['lbds_obvert'].append(np.nan)
                d['rbds_obvert'].append(np.nan)

        self._channel_info = pd.DataFrame(d)
        self._channel_info.set_index('id', inplace=True)

    def _init_lp(self, dfconn: pd.DataFrame) -> pd.DataFrame:
        df = self._lp.init_lp(dfconn)
        df['node'] = df['node'].str.split('_', n=2).str[-1]
        return df

    def _figure_out_loc_and_data_types_lp(self, locations: str | list[str],
                                          data_types: str | list[str] | None,
                                          filter_by: str) -> tuple[list[str], list[str]]:
        return super()._figure_out_loc_and_data_types_lp(locations, data_types, 'node')
