
"""PyGFETdb DataClass utilities.

This module provides the core data container and helpers used by the
PyGFETdb package to represent, manipulate and analyse GFET (graphene
field-effect transistor) measurement data. It includes:

- DataCharDC: container and helper methods for DC device
  characteristics (Ids, gm, Rds, Ud0, polynomial fits, etc.).
- DataCharAC: extends DataCharDC with frequency-domain helpers
  (PSD interpolation, noise fitting, IRMS calculation, bode
  parameters, etc.).
- Several noise model helper functions and fitter wrappers used to
  extract 1/f (flicker) and thermal noise components from PSD data.
- PyFETPlotDataClass: plotting helper that wraps PlotDataClass plotting
  primitives for PyGFETdb data objects.

Only non-invasive documentation and docstring improvements are made
in this file to help developers and tools understand the public API.
"""

import numpy as np
from scipy.interpolate import make_splrep, make_smoothing_spline, interp1d
import scipy.optimize as optim
from scipy.integrate import simpson as simps
import quantities as pq
import copy

from PyGFETdb import PlotDataClass
import sys

DebugPrint = True

ParametersList = ['GM',
                  'GMV',
                  'GMabs',
                  'Ig',
                  'Ids',
                  'Irms',
                  'Vrms',
                  'Rds',
                  'NoA',
                  'NoB',
                  'NoC',
                  'FEMmu',
                  'FEMn',
                  'FEMmuGm',
                  'Ud0',
                  'IgMax',
                  'GmFcut',
                  'GmF0',
                  'GmErr',
                  ]

class DataCharDC(object):
    """Container and helpers for DC GFET characteristics.

    This class wraps a dictionary that comes from the database and
    converts numerical arrays into quantities with physical units
    (using the :mod:`quantities` package). It provides convenience
    methods to compute and return common derived quantities such as
    Ids (drain current), gm (transconductance), Rds (drain-source
    resistance), Ud0 (point of minimum conductivity / Dirac point),
    fitted polynomial representations (IdsPoly, GMPoly) and simple
    field-effect mobility estimates (CalcFEM).

    Typical usage:

    >>> d = DataCharDC(db_record)
    >>> ids = d.GetIds(Vgs=np.array([-0.1, 0, 0.1])*pq.V)

    Parameters
    ----------
    Data : dict
        Dictionary with fields from the database. Keys expected by the
        class are documented in the code and include e.g. 'Vgs', 'Vds',
        'Ids', 'Gm', 'Gate', 'TrtTypes', 'Info'.

    Important attributes created by the constructor (non-exhaustive)
    ---------------------------------------------------------------
    - Vgs, Vds : arrays with the gate and drain voltages
    - Ids, gm, PSD : raw measurement arrays (converted to quantities)
    - IdsPoly, GMPoly : spline/smoothing-spline representations used
      to compute interpolated values
    - Ud0 : estimated Dirac point for each Vds

    The class methods follow a GetX convention (GetIds, GetGM, etc.)
    and mostly return quantities with the units defined in
    ``DefaultUnits``.
    """
    VgsOffset = 0 * pq.V
    # MaxPolyOrder = 15
#    FEMn0 = 8e11  # 1/cm^2
#    FEMq = 1.602176565e-19
#    FEMRc = 300  # absolute value Ohms
#    FEMCdl = 2e-6  # F/cm^2
    IntMethod = 'linear'
    DefaultUnits = {'Vds': pq.V,
                    'VdsDC': pq.V,
                    'Ud0': pq.V,
                    'PSD': pq.A ** 2 / pq.Hz,
                    'gm': pq.S,
                    'Fgm': pq.Hz,
                    'GM': pq.S,
                    'GMV': pq.S / pq.V,
                    'Vgs': pq.V,
                    'VgsDC': pq.V,
                    'Fpsd': pq.Hz,
                    'Ig': pq.A,
                    'Irms': pq.A,
                    'Vrms': pq.V,
                    'Ids': pq.A,
                    'Rds': pq.ohm,
                    'NoA': pq.A**2,
                    'NoC': pq.A**2,
                    'NoB': pq.dimensionless,
                    'FEMmu': pq.cm**2/(pq.s*pq.V),
                    'FEMn': pq.cm**-2,
                    'FEMmuGm': pq.cm**2/(pq.s*pq.V),
                    'GmFcut': pq.Hz,
                    'GmF0': pq.S,
                    'GmErr': pq.dimensionless,
                    }

    def RawData(self):
        """Return a plain-serializable dictionary of stored attributes.

        The returned dictionary converts :class:`quantities.Quantity`
        values into their raw magnitudes so the result can be
        serialized (for example, to JSON or to a database). Nested
        dictionaries are handled recursively for one level.

        Returns
        -------
        dict
            Copy of ``self.__dict__`` with quantities replaced by their
            magnitude values.
        """
        Data = {}
        for k, d in self.__dict__.items():
            if type(d) == pq.Quantity:
                Data[k] = d.magnitude
            elif type(d) == dict:
                dic = {}
                for kk, dd in d.items():
                    if type(dd) == pq.Quantity:
                        dic[kk] = dd.magnitude
                    else:
                        dic[kk] = dd
                Data[k] = dic
            else:
                Data[k] = d
        return Data

    def _CalcIsOK(self, RdsRange=(400, 10e3)):
        """Set the ``IsOK`` flag based on Rds being within limits.

        Parameters
        ----------
        RdsRange : tuple, optional
            Allowed (min, max) range for Rds in Ohms. If any Rds values
            fall outside the range, ``self.IsOK`` will be set to False.
        """
        Rds = self.GetRds()
        if np.any([Rds < RdsRange[0], Rds > RdsRange[1]]):
            self.IsOK = False
        else:
            self.IsOK = True

    def __CheckData(self, Data):
        """Perform basic validation and cleaning of input dictionary.

        This routine checks for NaNs in critical arrays, validates
        presence and size of Vgs vectors and ensures frequency arrays
        are usable. On problems it may drop NaN entries or return None
        to indicate the data is unusable.

        Parameters
        ----------
        Data : dict
            Input dictionary coming from the database.

        Returns
        -------
        dict or None
            Cleaned dictionary or ``None`` if the data is invalid.
        """
        if 'Ids' in Data:
            nanidsInds = np.where(np.isnan(Data['Ids']))[0]
            if len(nanidsInds):
                print('Detected nan values', Data['Vgs'][nanidsInds])
                Data['Ids'] = np.delete(Data['Ids'], nanidsInds, axis=0)
                Data['Vgs'] = np.delete(Data['Vgs'], nanidsInds)
                Data['Slope'] = np.delete(Data['Slope'], nanidsInds, axis=0)

        if 'VgsDC' in Data:
            vgs = Data['VgsDC']
        else:
            vgs = Data['Vgs']

        if len(vgs) == 0:
            print('No Vgs data')
            return None
        elif len(vgs) < 10:
            print('{} Data is too short ({} points)'.format(Data['Name'],
                                                           len(vgs)))

        if 'Fgm' in Data:
            if Data['Fgm'].size == 0:
                Data['Fgm'] = None

        return Data

    def __init__(self, Data, ForceRecalcPoly=True):
        """
        Create a class to manage the GFET characteristics.

        Parameters
        ----------
        Data: Dictionary from DB

        """
        
        Data = self.__CheckData(Data)
        if Data is None:
            self.DataError= True
            return
        self.DataError = False

        for k, v in Data.items():
            if k == 'Gate':
                if v is None:
                    if DebugPrint:
                        print('No gate values')
                elif np.isnan(v['Ig']).any():
                    if DebugPrint:
                        print('nan in gate values')
                else:
                    self.__setattr__('Ig', v['Ig'])
            if k in self.DefaultUnits:
                if k == 'PSD' or k == 'gm':
                    d = {}
                    for kk, vv in v.items():
                        d[kk] = vv * self.DefaultUnits[k]
                    self.__setattr__(k, d)
                else:
                    if v is None:
                        continue
                    v = v * self.DefaultUnits[k]
                    self.__setattr__(k, v)
            else:
                if k == 'ChName':
                    if type(v) == np.bytes_:
                        v = v.decode()
                self.__setattr__(k, v)

        if ForceRecalcPoly:
            self.CalcIdsPoly()
            self.CalcGMPoly()
            self.CalcUd0()

        if 'Ud0' not in Data:
            self.CalcUd0()

        if 'IsOK' not in Data:
            self._CalcIsOK()

        if 'GMPoly' not in Data:
            self.CalcGMPoly()

    def __getitem__(self, key):
        """Dictionary-style attribute access.

        Allows accessing stored attributes using indexing syntax
        (e.g. instance['Vgs']). If the key does not exist an empty
        dictionary is returned.
        """
        if key not in self.__dict__:
            return {}
        return self.__dict__[key]

    def _FormatOutput(self, Par, **kwargs):
        """Format a parameter array for consistent external output.

        Many GetX functions in this class return 2D arrays with shape
        (nVg, nVds). This helper enforces a consistent shape and applies
        unit rescaling if a ``Units`` keyword argument is provided.

        Parameters
        ----------
        Par : array-like or :class:`quantities.Quantity`
            Parameter to format.
        Units : str or :class:`quantities.Unit`, optional
            If provided, ``Par`` will be rescaled to these units.

        Returns
        -------
        numpy.ndarray or Quantity
            Formatted (and possibly rescaled) parameter array with a
            predictable 2D shape.
        """
        if 'Units' in kwargs:
            Par = Par.rescale(kwargs['Units'])

        if not hasattr(Par, '__iter__'):
            return Par[None, None]
        s = Par.shape
        if len(s) == 0:
            return Par[None, None]
        if len(s) == 1:
            return Par[:, None]
        return Par.transpose()

    def UpdateData(self, Data):
        """Update attributes on the instance from a dictionary.

        This is a convenience setter used to copy fields from a
        dictionary (for example, after recalculating parameters or
        loading partial updates from the DB).

        Parameters
        ----------
        Data : dict
            Mapping of attribute names to values to set on this object.
        """
        for k, v in Data.items():
            self.__setattr__(k, v)

    def CalcUd0(self):
        """Estimate Ud0 (Dirac point / minimum conductivity) per Vds.

        The method evaluates the fitted Ids polynomial on a dense Vgs
        grid and finds the Vgs value that minimizes Ids for each Vds.
        The result is stored in the ``Ud0`` attribute as a Quantity in
        volts.
        """
        if 'IdsPoly' not in self.__dict__:
            self.CalcIdsPoly()

        if 'VgsDC' in self.__dict__:
            Vgs = self.VgsDC
        else:
            Vgs = self.Vgs

        vgs = np.linspace(Vgs[0], Vgs[-1], len(Vgs)*10000)

        self.Ud0 = np.ones(len(self.Vds))*np.nan
        for ivd, Vds in enumerate(self.Vds):
            self.Ud0[ivd] = vgs[np.argmin(self.IdsPoly[ivd](vgs.magnitude))]
        self.Ud0 = self.Ud0 * pq.V

    def CalcIdsPoly(self):
        """Create smoothing-spline representations of Ids(Vgs) per Vds.

        The generated splines are stored in the attribute ``IdsPoly``
        as a list indexed by Vds index and are used by interpolation
        routines such as :meth:`GetIds`.
        """
        if 'VgsDC' in self.__dict__:
            Vgs = self.VgsDC
        else:
            Vgs = self.Vgs

        self.IdsPoly = []
        for ivd, Vds in enumerate(self.Vds):
            self.IdsPoly.append(make_smoothing_spline(Vgs, self.Ids[:, ivd]))

    def CalcGMPoly(self):
        """Create derivative (gm) spline objects from Ids splines.

        The derivative of the Ids smoothing-spline is stored in
        ``GMPoly`` and used to compute GM values at arbitrary Vgs
        points.
        """
        if 'IdsPoly' not in self.__dict__:
            self.CalcIdsPoly()

        self.GMPoly = []

        for ivd, Vds in enumerate(self.Vds):
            self.GMPoly.append(self.IdsPoly[ivd].derivative())

    def CalcFEM(self,
                FEMn0=8e11,  # 1/cm^2
                FEMq=1.602176565e-19,
                FEMRc=300,  # absolute value Ohms
                FEMCdl=2e-6,
                FEMRcVgs=None, **kwargs):  # F/cm^2)
        """Estimate simple field-effect mobility and carrier density.

        This method uses a simple compact model with a contact series
        resistance (FEMRc) and an effective gate capacitance (FEMCdl)
        to compute per-Vgs and per-Vds estimates of carrier density
        (FEMn) and mobility (FEMmu and FEMmuGm). Results are stored
        as attributes on the instance.

        Parameters
        ----------
        FEMn0, FEMq, FEMRc, FEMCdl : float
            Physical model parameters (default values provided).
        FEMRcVgs : array-like, optional
            Optional Vgs-dependent contact resistance to use instead
            of a single FEMRc value.
        """
        # TODO interpolate IDSpoly from all vgs....
        if 'IdsPoly' not in self.__dict__:
            self.CalcIdsPoly()

        self.FEMn = np.ones((len(self.Vgs), len(self.Vds)))*np.nan
        self.FEMmu = np.ones((len(self.Vgs), len(self.Vds)))*np.nan
        self.FEMmuGm = np.ones((len(self.Vgs), len(self.Vds)))*np.nan

        if FEMRcVgs is not None:
            FEMRc = np.ones(len(self.Vgs))*np.nan
            RcVgs = FEMRcVgs[0, :]
            RcVgsRc = FEMRcVgs[1, :]
            rcint = interp1d(RcVgs, RcVgsRc)
            Vgmeas = self.GetVgs(Ud0Norm=True)
            VgInds =  np.where((Vgmeas>np.min(RcVgs)) & (Vgmeas<np.max(RcVgs)))[0]
            FEMRc[VgInds] = rcint(Vgmeas[VgInds, 0])

        # print(FEMRc, FEMCdl)

        L = self.TrtTypes['Length']
        W = self.TrtTypes['Width']

        VgUd = np.abs(self.GetVgs(Ud0Norm=True)).magnitude
        Ids = self.GetIds().magnitude
        Gm = np.abs(self.GetGM()).magnitude
        for ivd, Vds in enumerate(self.Vds.magnitude):
            n = (FEMCdl * VgUd[:, ivd])/FEMq
            self.FEMn[:, ivd] = np.sqrt(n**2 + FEMn0**2)

            Ieff = Vds/(Vds/Ids[:, ivd] - FEMRc)
            mu = (Ieff*L)/(W*Vds*n*FEMq)
            self.FEMmu[:, ivd] = mu

            Vdseff = Vds - Ids[:, ivd]*FEMRc
            muGM = (Gm[:, ivd]*L)/(FEMCdl*Vdseff*W)
            self.FEMmuGm[:, ivd] = muGM

        self.FEMn *= self.DefaultUnits['FEMn']
        self.FEMmu *= self.DefaultUnits['FEMn']
        self.FEMmuGm *= self.DefaultUnits['FEMn']

    def GetUd0(self, Vds=None, Vgs=None, Ud0Norm=False,
               Normalize=False, **kwargs):
        """Return Ud0 values formatted for external consumption.

        Parameters
        ----------
        Vds : float or sequence, optional
            Vds value(s) to select. If omitted the method returns Ud0
            for all stored Vds.
        Vgs : unused (kept for signature compatibility)
        Ud0Norm : bool
            If True the requested Vgs values are considered referenced
            to the Dirac point (used for index selection elsewhere).
        Normalize : bool
            If True, return Ud0 normalized by subtracting Vds/2.

        Returns
        -------
        Quantity or ndarray
            Ud0 values with units of volts in a consistent shaped
            array as produced by :meth:`_FormatOutput`.
        """
        if 'Ud0' not in self.__dict__:
            self.CalcUd0()

        iVds = self.GetVdsIndexes(Vds)
        if len(iVds) == 0:
            return None

        Ud0 = np.array([])
        for ivd in iVds:
            ud0 = self.Ud0[ivd]
            if Normalize:
                ud0 = ud0-(self.Vds[ivd]/2)
            Ud0 = np.vstack((Ud0, ud0)) * pq.V if Ud0.size else ud0

        return self._FormatOutput(Ud0, **kwargs)

    def GetDateTime(self, **kwargs):
        """Return the stored acquisition date/time.

        Returns
        -------
        datetime-like
            The raw DateTime attribute as stored in the instance.
        """
        return self.DateTime

    def GetTime(self, **kwargs):
        """Return the stored acquisition time as a shaped numpy value.

        The returned value is shaped so it is compatible with the
        2D data returned by other Get* methods.
        """
        return np.datetime64(self.DateTime)[None, None].transpose()

    def GetVds(self, **kwargs):
        """Return the Vds array formatted for external consumption.

        Any ``Units`` keyword argument is passed to :meth:`_FormatOutput`.
        """
        return self._FormatOutput(self.Vds, **kwargs)

    def GetVgs(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return gate-voltage grid, optionally normalized by Ud0.

        Parameters
        ----------
        Vgs : ignored (kept for compatibility)
        Vds : select Vds index(es) for Ud0 normalization
        Ud0Norm : bool
            If True subtract Ud0 for the selected Vds from stored Vgs
            so the returned voltages are referenced to the Dirac point.

        Returns
        -------
        Quantity or ndarray
            Vgs array or a 2D array with rows corresponding to the
            requested Vds indices when Ud0Norm is True.
        """
        if not Ud0Norm:
            return self.Vgs

        iVds = self.GetVdsIndexes(Vds)
        if len(iVds) == 0:
            return None

        if 'Ud0' not in self.__dict__:
            self.CalcUd0()

        Vgs = np.array([])
        for ivd in iVds:
            vgs = self.Vgs - self.Ud0[ivd]
            Vgs = np.vstack((Vgs, vgs)) if Vgs.size else vgs

        return self._FormatOutput(Vgs, **kwargs)

    def GetVdsIndexes(self, Vds, MeasVds=None):
        """Resolve requested Vds values into stored index positions.

        Parameters
        ----------
        Vds : scalar, sequence or None
            Requested Vds value(s). If None all stored Vds indices are
            returned.
        MeasVds : array-like, optional
            Use this array instead of ``self.Vds`` when resolving
            indices (useful when selecting by measured Vds grids).

        Returns
        -------
        numpy.ndarray
            Array of integer indices corresponding to the requested
            Vds values.
        """
        if MeasVds is None:
            selfVds = self.Vds
        else:
            selfVds = MeasVds

        if Vds:
            if not hasattr(Vds, '__iter__'):
                Vds = (Vds,)
            iVds = []
            for vd in Vds:
                ind = np.where(selfVds == vd)[0]
                if len(ind) > 0:
                    iVds.append(ind[0])
                else:
                    print('Vds = ', vd, 'Not in data')
            iVds = np.array(iVds)
        else:
            iVds = np.arange(len(selfVds))
        return iVds

    def GetIds(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """
        Return Ids values interpolated by the polinomial fit.

        Parameters
        ----------
        vgs: vector with vgs values to calc, the invalid points will be nan
        Vds: vector with vds values to calc
        Ud0Norm: True, indicates that the vgs values are refered to CNP
        kwargs: Other arguments, like Units
        Return
        ----------
        Array with Ids values (len(Vgs),len(Vds)) nan in the invalid Vgs points

        """
        # Get Valid Vds indexes
        iVds = self.GetVdsIndexes(Vds)
        if len(iVds) == 0:
            return None

        # Check self consitency
        if len(self.Vgs) < 2:
            print('self Vgs len error', self.Vgs)
            return None
        if 'IdsPoly' not in self.__dict__:
            self.CalcIdsPoly()

        # Get Valid Vgs indexes
        if 'VgsDC' in self.__dict__:
            measVgs = self.VgsDC
        else:
            measVgs = self.Vgs

        vgs, vginds = self.CheckVgsInds(Vgs, iVds, Ud0Norm, MeasVgs=measVgs)
        if vgs is None:
            return np.ones((1, iVds.size)) * np.nan

        # Dimensioning output
        if Vgs is None:
            nVg = len(measVgs)
        else:
            nVg = Vgs.size
        Ids = np.ones((nVg, iVds.size)) * np.nan

        # Get Values
        for ic, ivd in enumerate(iVds):
            if Ud0Norm and Vgs is not None:
                vg = vgs + self.Ud0[ivd]
            else:
                vg = vgs
            ids = self.IdsPoly[ivd](vg.rescale('V').magnitude)
            Ids[vginds, ic] = ids

        # Check for units
        Ids = Ids * self.DefaultUnits['Ids']
        return self._FormatOutput(Ids, **kwargs)

    def GetGM(self, Vgs=None, Vds=None, Normalize=False,
              Ud0Norm=False, **kwargs):
        """
        Return GM values interpolated by the polinomial fit.

        Parameters
        ----------
        vgs: vector with vgs values to calc, the invalid points will be nan
        Vds: vector with vds values to calc
        Ud0Norm: True, indicates that the vgs values are refered to CNP
        Normalize: True, GM / Vds
        kwargs: Other arguments, like Units

        Return
        ----------
        Array with GM values (len(Vgs),len(Vds)) nan in the invalid Vgs points

        """
        # Get Valid Vds indexes
        iVds = self.GetVdsIndexes(Vds)
        if len(iVds) == 0:
            return None

        # Check self consitency
        if 'GMPoly' not in self.__dict__:
            self.CalcGMPoly()

        # Get Valid Vgs indexes
        if 'VgsDC' in self.__dict__:
            measVgs = self.VgsDC
        else:
            measVgs = self.Vgs

        vgs, vginds = self.CheckVgsInds(Vgs, iVds, Ud0Norm, MeasVgs=measVgs)
        if vgs is None:
            return np.ones((1, iVds.size)) * np.nan

        # Dimensioning output
        if Vgs is None:
            nVg = len(self.Vgs)
        else:
            nVg = Vgs.size
        GM = np.ones((nVg, iVds.size)) * np.nan

        # Get Values
        for ic, ivd in enumerate(iVds):
            if Ud0Norm and Vgs is not None:
                vg = vgs + self.Ud0[ivd]
            else:
                vg = vgs
            gm = self.GMPoly[ivd](vg.rescale('V').magnitude)
            if Normalize:
                gm = gm/self.Vds[ivd]
                # *(self.TrtTypes['Length']/self.TrtTypes['Width'])/
            GM[vginds, ic] = gm

        # Check for units
        if Normalize:
            GM = GM * self.DefaultUnits['GMV']
        else:
            GM = GM * self.DefaultUnits['GM']
        return self._FormatOutput(GM, **kwargs)

    def GetGMV(self, AbsVal=True, **kwargs):
        """Return GM normalized by Vds (GM/V) optionally as absolute.

        Parameters
        ----------
        AbsVal : bool
            If True return the absolute value of GM/V.
        Other keyword arguments are forwarded to

        Returns
        -------
        Quantity
            Normalized transconductance (GM/V).

        """
        kwargs.update({'Normalize': True})
        if AbsVal:
            return np.abs(self.GetGM(**kwargs))
        else:
            return self.GetGM(**kwargs)

    def GetGMabs(self, **kwargs):
        """Return the absolute value of the transconductance GM.

        All keyword arguments are forwarded to :meth:`GetGM`.
        """
        return np.abs(self.GetGM(**kwargs))

    def GetGMVWL(self, AbsVal=True, **kwargs):
        """Return normalized GM/V scaled by Width/Length.

        This returns GM/V multiplied by (Length/Width) which is useful
        for normalizing device-to-device. ``AbsVal`` controls whether
        the magnitude is returned.
        """
        kwargs.update({'Normalize': True})
        if AbsVal:
            return np.abs(self.GetGM(**kwargs))*(self.TrtTypes['Length']/self.TrtTypes['Width'])
        else:
            return self.GetGM(**kwargs)*(self.TrtTypes['Length']/self.TrtTypes['Width'])
        
    def GetGMMax(self, **kwargs):
        """Return the maximum absolute GM over the requested grid.

        All kwargs are forwarded to :meth:`GetGM`.
        """
        return np.max(np.abs(self.GetGM(**kwargs)))
    
    
    def GetRds(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Compute drain-source resistance Rds = Vds / Ids.

        Parameters
        ----------
        Vgs, Vds, Ud0Norm : passed to :meth:`GetIds` to select the
            evaluation grid.

        Returns
        -------
        Quantity
            Array with Rds values in Ohms, shaped consistently via
            :meth:`_FormatOutput`.
        """
        Ids = self.GetIds(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)

        if Ids is None:
            return None

        iVds = self.GetVdsIndexes(Vds)

        Rds = np.ndarray(Ids.shape) * pq.Ohm
        for iid, ivd in enumerate(iVds):            
            Rds[iid, :] = (self.Vds[ivd]/Ids[iid, :])

        return self._FormatOutput(Rds.transpose(), **kwargs)
    def GetConductivity(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return sheet conductivity sigma from Rds and device geometry.

        sigma = (1/Rds) * (Length/Width)
        """
        Rds = self.GetRds(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)   
        sigma = (1/Rds)*(self.TrtTypes['Length']/self.TrtTypes['Width'])

        return self._FormatOutput(sigma.transpose(), **kwargs)

    def GetFEMn(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return estimated carrier density (FEMn).

        Computes FEM quantities on demand via :meth:`CalcFEM` if
        needed and returns values formatted via :meth:`_GetParam`.
        """
        if 'FEMn' not in self.__dict__:
            self.CalcFEM(**kwargs)
        return self._GetParam('FEMn', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm,**kwargs)

    def GetFEMmu(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return estimated field-effect mobility (FEMmu).

        Calls :meth:`CalcFEM` lazily and returns values via
        :meth:`_GetParam`.
        """
        if 'FEMmu' not in self.__dict__:
            self.CalcFEM(**kwargs)
        return self._GetParam('FEMmu', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm,**kwargs)

    def GetFEMmuGm(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return mobility estimated from gm (FEMmuGm).

        Calls :meth:`CalcFEM` if the parameter has not been calculated
        yet.
        """
        if 'FEMmuGm' not in self.__dict__:
            self.CalcFEM(**kwargs)
        return self._GetParam('FEMmuGm', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm,**kwargs)

    def GetIg(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return gate leakage current (Ig) interpolated to requested grid.

        If the gate data is not available this returns None. When
        present the measured gate Vgs/Vds grids from ``self.Gate`` are
        used for interpolation.
        """
        if 'Ig' not in self.__dict__:
            return None
        return self._GetParam('Ig',
                              Vgs=Vgs,
                              Vds=Vds,
                              Ud0Norm=Ud0Norm,
                              MeasVgs=self.Gate['Vgs'],
                              MeasVds=self.Gate['Vds'],
                              )
    
    def GetIgMax(self, **kwargs):
        """Return the maximum absolute gate current across the grid."""
        return np.max(np.abs(self.GetIg(**kwargs)))
    
    def CheckVgsInds(self, Vgs, iVds, Ud0Norm, MeasVgs=None):
        """
        Find the valid indexes for the vgs vector.

        Parameters
        ----------
        Vgs: vector with the vgs values to check
        iVds: list of indexes for Vds
        Ud0Norm: True, indicates that the vgs values are refered to CNP

        Return
        ----------
        Vector with valid Vgs values
        Vector with indexes of valid Vgs points referred to the input Vgs

        """
        # TODO !!!!!! check for serveral Vds

        if MeasVgs is None:
            selfVgs = self.Vgs
        else:
            selfVgs = MeasVgs

        if Vgs is None:
            return selfVgs, np.arange(len(selfVgs))

        for ivd in iVds:
            VgsM = selfVgs
            if Ud0Norm is None or Ud0Norm is False:
                vg = Vgs
            else:
                vg = Vgs + self.Ud0[ivd]

            if (np.min(vg) < np.min(VgsM)) or (np.max(vg) > np.max(VgsM)):
                if vg.size == 1:
                    return None, None

                vgmin = (vg - self.VgsOffset)
                vgmax = (vg + self.VgsOffset)
                Inds = np.where((vgmin > np.min(VgsM)) & (vgmax < np.max(VgsM)))
                # Inds = np.where((vg > np.min(VgsM)) & (vg < np.max(VgsM)))
                # print('\n', self.Name, '\n')
                # print('\n Vgs asked', Vgs, vg, vg.size)
                # print('Vgs Meas', VgsM)
                # print('Valid Vgs range', np.min(vg[Inds]), np.max(vg[Inds]),
                #       '\n len inds len Vg', len(Inds), len(vg))
                return Vgs[Inds], Inds
        return Vgs, np.arange(Vgs.size)

    def CheckVgsRange(self, Vgs, iVds, Ud0Norm, MeasVgs=None):
        """Verify that requested Vgs are within the measured range.

        Returns the input Vgs if fully contained in the measured
        ``MeasVgs`` range, otherwise returns None and prints a
        diagnostic.
        """
        if MeasVgs is None:
            selfVgs = self.Vgs
        else:
            selfVgs = MeasVgs

        if Vgs is not None:
            for ivd in iVds:
                VgsM = selfVgs
                if Ud0Norm is None or Ud0Norm is False:
                    vg = Vgs
                else:
                    vg = Vgs + self.Ud0[ivd]

                if (np.min(vg) < np.min(VgsM)) or (np.max(vg) > np.max(VgsM)):
                    Inds = np.where((vg > np.min(VgsM)) & (vg < np.max(VgsM)))
                    print(self.Name, 'Valid Vgs range', vg[Inds])
                    return None
            return Vgs
        else:
            return selfVgs

    def Get(self, Param, **kwargs):
        """
        Get any kind of value, generic form.

        Parameters
        ----------
        Param: String with the name of the parameter, 'Ids', 'Vrms'
        **kwargs: arguments for the calling function

        Return
        ----------
        Values

        """
        return self.__getattribute__('Get' + Param)(**kwargs)

    def _GetParam(self, Param, Vgs=None, Vds=None, Ud0Norm=False, Normalize=False,
                  MeasVgs=None, MeasVds=None, **kwargs):
        """
        Get any kind of value, generic form.

        Parameters
        ----------
        Param: String with the name of the parameter, 'Ids', 'Vrms'
        **kwargs: arguments for the calling function

        Return
        ----------
        Values

        """
        # Get Valid Vds indexes
        iVds = self.GetVdsIndexes(Vds=Vds, MeasVds=MeasVds)
        if len(iVds) == 0:
            return None

        # Check self consistency
        if Param not in self.__dict__:
            print(Param, 'Not valid parameter', self.Name)
            return None
        Par = self.__getattribute__(Param)

        # Get Valid Vgs indexes
        vgs, vginds = self.CheckVgsInds(Vgs=Vgs,
                                        iVds=iVds,
                                        Ud0Norm=Ud0Norm,
                                        MeasVgs=MeasVgs)
        if vgs is None:
            return np.ones((1, iVds.size)) * np.nan

        if MeasVgs is None:
            selfVgs = self.Vgs
        else:
            selfVgs = MeasVgs

        if MeasVds is None:
            selfVds = self.Vds
        else:
            selfVds = MeasVds

        # Dimensioning output
        if Vgs is None:
            nVg = len(selfVgs)
        else:
            nVg = Vgs.size
        PAR = np.ones((nVg, iVds.size)) * np.nan

        # Get values
        for ic, ivd in enumerate(iVds):
            if Ud0Norm and Vgs is not None:
                vg = vgs + self.Ud0[ivd]
            else:
                vg = vgs

            par = interp1d(selfVgs, Par[:, ivd], kind=self.IntMethod)(vg)
            if Normalize:
                par = par/selfVds[ivd]
            PAR[vginds, ic] = par

        if Param in self.DefaultUnits:
            # print(Param, self.DefaultUnits[Param])
            PAR = PAR * self.DefaultUnits[Param]

        return self._FormatOutput(PAR, **kwargs)

    def GetName(self, **kwargs):
        """Get the device name."""
        return self.Name

    def GetWL(self, **kwargs):
        """Get the Width Length ratio."""
        return self.TrtTypes['Width']/self.TrtTypes['Length']

    def GetPass(self, **kwargs):
        """Get the Passivation Length."""
        return self.TrtTypes['Pass']

    def GetLength(self, **kwargs):
        """Return treatment length metadata.

        Returns the length (typically a physical device dimension)
        stored under the ``TrtTypes`` attribute.
        """
        return self.TrtTypes['Length']

    def GetWidth(self, **kwargs):
        """Return treatment width metadata.

        Returns the width (typically a physical device dimension)
        stored under the ``TrtTypes`` attribute.
        """
        return self.TrtTypes['Width']

    def GetContact(self, **kwargs):
        """Return contact type metadata for the device.

        The contact information is read from ``TrtTypes['Contact']``.
        """
        return self.TrtTypes['Contact']

    def GetTypeName(self, **kwargs):
        """Return the treatment/type name for this device.

        This is a convenience accessor for ``TrtTypes['Name']``.
        """
        return self.TrtTypes['Name']

    def GetPh(self, **kwargs):
        """Return the pH value(s) associated with the measurement.

        The returned value is shaped for compatibility with other
        Get* methods.
        """
        return np.array(self.Info['Ph'])[None, None]

    def GetIonStrength(self, **kwargs):
        """Return the ionic strength associated with the measurement.

        The value is returned as a 2D shaped array for compatibility
        with other getters.
        """
        return np.array(self.Info['IonStrength'])[None, None]

    def GetFuncStep(self, **kwargs):
        """Return the functional step information from metadata.

        This accessor returns ``Info['FuncStep']`` which typically
        describes the experimental protocol step used during the
        measurement.
        """
        return self.Info['FuncStep']

    def GetComments(self, **kwargs):
        """Return user comments stored with the measurement.

        Returns the string stored in ``Info['Comments']``.
        """
        return self.Info['Comments']

    def GetAnalyteCon(self, **kwargs):
        """Return the analyte concentration as a shaped array.

        The value is returned in a form compatible with other Get*
        accessors.
        """
        return np.array(self.Info['AnalyteCon'])[None, None]

    def GetGds(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return device conductance Gds = 1/Rds for the requested grid."""
        Gds = 1 / self.GetRds(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)
        return Gds

    def GetGMNorm(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return GM normalized by Vds (shorthand wrapper)."""
        GMNorm = self.GetGM(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm, Normalize=True)
        return GMNorm

    def GetUd0Vds(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return Ud0 normalized by Vds (wrapper for :meth:`GetUd0`)."""
        return self.GetUd0(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm, Normalize=True)

    def GetCVCycle(self, **kwargs):
        """Get the CV Cycle number."""
        value = getattr(self, 'CVCycle', None)
        try:
            return int(value)
        except (TypeError, ValueError):
            return None


    def GetCVSing(self, **kwargs):
        """Get the CV Singularity number."""
        value = getattr(self, 'CVSing', None)
        try:
            return str(value)
        except (TypeError, ValueError):
            return None

    def GetCVShape(self, **kwargs):
        """Get the CV Shape."""
        value = getattr(self, 'CVShape', None)
        try:
            return str(value)
        except (TypeError, ValueError):
            return None

    def GetCVSlewRate(self, **kwargs):
        """Get the CV Slew Rate."""
        value = getattr(self, 'CVSlewRate', None)
        try:
            return float(value)
        except (TypeError, ValueError):
            return None

    def GetCVFrequency(self, **kwargs):
        """Get the CV Frequency."""
        value = getattr(self, 'CVFrequency', None)
        try:
            return float(value)
        except (TypeError, ValueError):
            return None

def Fnoise(f, a, b):
    """Simple 1/f noise model.

    Parameters
    ----------
    f : array-like
        Frequency values at which to evaluate the model.
    a : float
        Flicker noise amplitude (prefactor).
    b : float
        Frequency exponent (typically close to 1 for 1/f noise).

    Returns
    -------
    ndarray
        Modelled noise power spectral density evaluated at ``f``.
    """

    return a/f**b


def LogFnoise(f, a, b):
    """Linear model for log10(noise) vs log10(frequency).

    This function returns a linear relationship used when fitting in
    log-log space: log10(PSD) = a + b*log10(f). The order of the
    returned expression matches the convention used by the fit helper
    functions in this module.

    Parameters
    ----------
    f : array-like
        Log10 of the frequency values (i.e. log10(f)).
    a : float
        Constant offset in log10(PSD).
    b : float
        Slope in log-log space (related to the frequency exponent).

    Returns
    -------
    ndarray
        Predicted log10(PSD) values.
    """

    return b*f+a


def FitFNoise(Freq, psd):
    """Fit the simple 1/f noise model to PSD data.

    Parameters
    ----------
    Freq : array-like
        Frequency values.
    psd : array-like
        Measured power spectral density values at ``Freq``.

    Returns
    -------
    a, b, err
        Fitted parameters ``a`` and ``b`` for the model a/f**b and an
        array with the estimated parameter standard errors (sqrt of
        the covariance diagonal).
    """

    poptV, pcov = optim.curve_fit(Fnoise, Freq, psd)
    a = poptV[0]
    b = poptV[1]

    return a, b, np.sqrt(np.diag(pcov))


def FitLogFnoise(Freq, psd):
    """Fit a log-log linear model to PSD data and return 1/f params.

    This routine fits log10(psd) = a + b*log10(f) and converts the
    fitted parameters back to the a/f**b convention used elsewhere in
    the code. The returned ``a`` is given in linear units (not log10)
    and ``b`` is the frequency exponent.

    Parameters
    ----------
    Freq : array-like
        Frequency values.
    psd : array-like
        Power spectral density values.

    Returns
    -------
    a, b, err
        Fitted flicker amplitude ``a``, exponent ``b`` and parameter
        standard errors.
    """

    poptV, pcov = optim.curve_fit(LogFnoise, np.log10(Freq),
                                  np.log10(psd))

    a = 10 ** poptV[0]
    b = - poptV[1]
    return a, b, np.sqrt(np.diag(pcov))


def FnoiseTh(f, a, b, c):
    """
    Flicker noise plus thermal noise.

    n = a/f^b + c

    Parameters
    ----------
    f: array frequency values
    a: flicker noise constant
    b: flicker noise frequency exponent
    c: flicker thermal noise constant value

    Return
    ----------
    noise array with the same shape as f

    """
    return (a/f**b)+c


def LogFnoiseTh(f, a, b, c):
    """
    Flicker noise plus thermal noise computed on log values to facilitate the
    fitting operation.

    n = a/f^b + c

    Parameters
    ----------
    f: array log10 frequency values
    a: flicker log10 noise constant
    b: flicker noise frequency exponent
    c: flicker log 10 thermal noise constant value

    Return
    ----------
    log10 noise array with the same shape as f

    """

    f1 = 10**f
    a1 = 10**a
    c1 = 10**c
    return np.log10(a1+c1*f1**b)-b*f


def FitLogFnoiseTh(Freq, psd):
    """
    Flicker noise plus thermal noise computed on log values to facilitate the
    fitting operation.

    n = a/f^b + c

    Parameters
    ----------
    f: array log10 frequency values
    a: flicker log10 noise constant
    b: flicker noise frequency exponent
    c: flicker log 10 thermal noise constant value

    Return
    ----------
    log10 noise array with the same shape as f

    """
    bound = ((-22, 0.7, -23),
             (-10, 1.2, -10))
    poptV, pcov = optim.curve_fit(LogFnoiseTh,
                                  np.log10(Freq),
                                  np.log10(psd),
                                  bounds=bound)

    # print(Freq)#, psd.Shape, Freq[0], Freq[-1])
    a = 10 ** poptV[0]
    b = poptV[1]
    c = 10 ** poptV[2]
    return a, b, c, np.sqrt(np.diag(pcov))


class DataCharAC(DataCharDC):
    """AC and frequency-domain analysis extensions for DataCharDC.

    DataCharAC adds helpers to interpolate power spectral density (PSD)
    data, fit flicker and thermal noise models, compute integrated
    RMS noise (Irms), and extract small-signal (AC) transfer
    function parameters (gm magnitude and phase). The class expects
    the underlying data dictionary to contain frequency-domain fields
    such as ``Fpsd``, ``PSD`` (per Vds slice) and ``gm``/``Fgm`` when
    available.

    The public API mostly follows the GetX convention from
    :class:`DataCharDC`, providing methods such as ``GetPSD``,
    ``GetIrms``, ``GetNoA`` and ``GetGmMag``.
    """
    def __init__(self, Data):
        self.__setattr__('FFmin', None)
        self.__setattr__('FFmax', None)
        self.__setattr__('NFmin', None)
        self.__setattr__('NFmax', None)
        self.__setattr__('GmFcut', None)
        self.__setattr__('GmF0', None)
        self.__setattr__('GmErr', None)

        super(DataCharAC, self).__init__(Data)

        if 'Fpsd' in self.__dict__:
            if self.Fpsd.size > 100:
                self.InterpolatePSD()
            if self.FFmin is None:
                self._CheckFitting(FFmin=5, FFmax=7e3)
            if self.NFmin is None:
                self._CheckRMS(NFmin=5, NFmax=5e3)

        if 'Fgm' in self.__dict__:
            self.CalcBodeParams()

    def InterpolatePSD(self, Points=100):
        """Interpolate PSD data to a logarithmic frequency grid.

        Parameters
        ----------
        Points : int
            Number of logarithmically spaced frequency points to
            interpolate the PSD onto.
        """
        Flin = np.array(self.Fpsd[1:])
        Flog = np.logspace(np.log10(Flin[0]),
                           np.log10(Flin[-1]),
                           Points)

        Flog = np.round(Flog, 9)
        Flin = np.round(Flin, 9)
        self.Fpsd = Flog * self.DefaultUnits['Fpsd']

        for Vds in self.PSD:
            PSDlin = self.PSD[Vds][:, 1:]
            psd = interp1d(Flin, PSDlin)
            self.PSD[Vds] = psd(Flog) * self.DefaultUnits['PSD']

    def _GetFreqVgsInd(self, Vgs=None, Vds=None, Ud0Norm=False):
        """Return PSD dictionary key(s) for Vds and Vgs index(es).

        This helper returns the PSD key names (e.g. 'Vd0') for the
        requested Vds selection and the indices of the nearest measured
        Vgs points for the requested Vgs values.
        """
        iVds = self.GetVdsIndexes(Vds)
        if len(iVds) == 0:
            return None, None

        vgs = self.CheckVgsRange(Vgs, iVds, Ud0Norm)
        if vgs is None:
            return None, None

# TODO check for more than 1 vds
        if Ud0Norm is True:
            vgs = vgs - self.Ud0[iVds[0]]

        VGS = self.GetVgs(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)
        vgsmeas = [min(VGS, key=lambda x:abs(x-vg)) for vg in vgs]
        VgsInd = [np.where(VGS == vg)[0][0] for vg in vgsmeas]

        SiVds = ['Vd{}'.format(i) for i in iVds]
        return SiVds, VgsInd

    def _CheckFreqIndexes(self, Freq, Fmin, Fmax):
        """Return indices selecting frequencies inside optional bounds.

        Parameters
        ----------
        Freq : array-like
            Frequency axis.
        Fmin, Fmax : float or None
            Lower and upper bounds. If both are None returns a default
            range that excludes the DC component.
        """
        if Fmin is not None and Fmax is not None:
            return np.where(np.logical_and(Freq > Fmin, Freq < Fmax))
        if Fmin is not None:
            return np.where(Freq > Fmin)
        if Fmax is not None:
            return np.where(Freq < Fmax)
        return range(len(Freq))[1:]

    def FitNoise(self, Fmin, Fmax):
        """Fit flicker+thermal noise model to PSD data across grid.

        Fills attributes ``NoA``, ``NoB``, ``NoC`` and their fitting
        uncertainties. The search is performed per Vds and per measured
        Vgs and uses :func:`FitLogFnoiseTh` to estimate parameters.
        """
        nVgs = len(self.Vgs)
        nVds = len(self.Vds)

        self.__setattr__('NoC', [])

        NoA = np.ones((nVgs, nVds))*np.nan
        NoB = np.ones((nVgs, nVds))*np.nan
        NoC = np.ones((nVgs, nVds))*np.nan
        FitErrA = np.ones((nVgs, nVds))*np.nan
        FitErrB = np.ones((nVgs, nVds))*np.nan
        FitErrC = np.ones((nVgs, nVds))*np.nan
        FitPSD = copy.deepcopy(self.PSD)

        for ivd in range(nVds):
            for ivg in range(nVgs):
                psd = self.PSD['Vd{}'.format(ivd)][ivg, :]
                Fpsd = self.Fpsd
                if np.any(np.isnan(psd)):
                    continue

                try:
                    Inds = self._CheckFreqIndexes(Fpsd, Fmin, Fmax)
                    # print(Fmin, Fmax, Inds)
                    a, b, c, err = FitLogFnoiseTh(Fpsd[Inds].magnitude,
                                                  psd[Inds].magnitude)
                    # print(a, b, c, err)
                    NoA[ivg, ivd] = a
                    NoB[ivg, ivd] = b
                    NoC[ivg, ivd] = c
                    FitErrA[ivg, ivd] = err[0]
                    FitErrB[ivg, ivd] = err[1]
                    FitErrC[ivg, ivd] = err[2]
                    fit = FnoiseTh(Fpsd.magnitude, a, b, c) * self.DefaultUnits['PSD']
                    FitPSD['Vd{}'.format(ivd)][ivg, :] = fit
                    self.NoA = NoA
                    self.NoB = NoB
                    self.NoC = NoC
                    self.FitErrA = FitErrA
                    self.FitErrB = FitErrB
                    self.FitErrC = FitErrC
                    self.FitPSD = FitPSD
                except:
                    print ("Fitting error:", sys.exc_info()[0])

    def CalcIRMS(self, Fmin, Fmax):
        """Compute integrated RMS current from PSD between Fmin and Fmax.

        The result is stored in ``self.Irms`` and has the same shape as
        the Vgs x Vds measurement grid.
        """
        nVgs = len(self.Vgs)
        nVds = len(self.Vds)

        Irms = np.ones((nVgs, nVds))*np.nan
        for ivd in range(nVds):
            for ivg in range(nVgs):
                psd = self.PSD['Vd{}'.format(ivd)][ivg, :]
                Fpsd = self.Fpsd
                if np.any(np.isnan(psd)):
                    continue

                Inds = self._CheckFreqIndexes(Fpsd, Fmin, Fmax)
                Irms[ivg, ivd] = np.sqrt(simps(psd[Inds], x=Fpsd[Inds]))
        self.Irms = Irms

    def GetFitPSD(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return fitted PSD curve for a requested Vgs/Vds selection.

        The fitted PSD is the model evaluated using parameters found by
        :meth:`FitNoise` and stored in ``self.FitPSD``.
        """
        SiVds, VgsInd = self._GetFreqVgsInd(Vgs, Vds, Ud0Norm)
        if VgsInd is None:
            return None
        return self.FitPSD[SiVds[0]][VgsInd, :].transpose()

    def CalcBodeParams(self, VgsRange=0.08):
        """Extract simple Bode parameters from small-signal gm data.

        Computes cutoff frequency (GmFcut), low-frequency GM value
        (GmF0) and the relative error between AC and DC gm (GmErr) for
        each Vgs/Vds point where gm data is available.
        """
        if self.Fgm is None:
            return

        nVgs = len(self.Vgs)
        nVds = len(self.Vds)

        GmFcut = np.ones((nVgs, nVds)) * np.nan
        GmF0 = np.ones((nVgs, nVds)) * np.nan
        GmErr = np.ones((nVgs, nVds)) * np.nan
        Fgm = self.GetFgm()
        for ivd in range(nVds):
            for ivg in range(nVgs):
                gmm = np.abs(self.gm['Vd{}'.format(ivd)][ivg, :])
                fInd = np.where(gmm < (gmm[0] / 2))[0]
                if len(fInd) > 0:
                    fInd = fInd[0]
                    GmFcut[ivg, ivd] = Fgm[fInd]

                GmF0[ivg, ivd] = gmm[0]
                GM = np.abs(self.GetGM(Vgs=self.Vgs[ivg], Vds=self.Vds[ivd].flatten()))
                if not np.isnan(GM):
                    GmErr[ivg, ivd] = (gmm[0] - GM) / GM

        self.GmFcut = GmFcut
        self.GmF0 = GmF0
        self.GmErr = GmErr

    def GetPSD(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return PSD array for requested Vgs/Vds selection.

        Returns the PSD as a 2D array with frequency along the last
        axis. If PSD data is not present the method returns None and
        prints a diagnostic.
        """
        if 'PSD' not in self.__dict__:
            print('PSD is not a valid parameter ', self.Name)
            return None
        SiVds, VgsInd = self._GetFreqVgsInd(Vgs, Vds, Ud0Norm)
        if VgsInd is None:
            return None
        return self.PSD[SiVds[0]][VgsInd, :].transpose()

    def GetGmMag(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return the magnitude of the small-signal gm for requested grid."""
        if 'gm' not in self.__dict__:
            print('gm is not a valid parameter ', self.Name)
            return None

        SiVds, VgsInd = self._GetFreqVgsInd(Vgs, Vds, Ud0Norm)
        if VgsInd is None:
            return None

        return np.abs(self.gm[SiVds[0]][VgsInd, :].transpose())

    def GetGmPh(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return the phase (degrees) of the small-signal gm response."""
        if 'gm' not in self.__dict__:
            print('gm is not a valid parameter ', self.Name)
            return None

        SiVds, VgsInd = self._GetFreqVgsInd(Vgs, Vds, Ud0Norm)
        if VgsInd is None:
            return None

        return np.angle(self.gm[SiVds[0]][VgsInd, :].transpose(), deg=True)

    def GetFpsd(self, **kwargs):
        """Return the frequency axis used for PSD measurements."""
        if 'Fpsd' not in self.__dict__:
            print('Fpsd is not a valid parameter ', self.Name)
            return None
        return self.Fpsd

    def GetFgm(self, **kwargs):
        """Return the frequency axis used for gm (bode) measurements."""
        if 'Fgm' not in self.__dict__:
            print('Fgm is not a valid parameter ', self.Name)
            return None
        return self.Fgm

    def _CheckRMS(self, NFmin, NFmax):
        """Update IRMS frequency bounds and recalculate if changed."""
        if NFmin is not None or NFmax is not None:
            if self.NFmin != NFmin or self.NFmax != NFmax:
                # print ('Calc IRMS')
                self.NFmin = NFmin
                self.NFmax = NFmax
                # if self.IsOK:
                self.CalcIRMS(Fmin=NFmin, Fmax=NFmax)

    def GetIrms(self, Vgs=None, Vds=None, Ud0Norm=False,
                NFmin=None, NFmax=None, **kwargs):
        """Return integrated RMS current for a requested grid.

        The method accepts optional NFmin/NFmax bounds which will be
        used to (re)compute IRMS if they differ from previous values.
        """
        self._CheckRMS(NFmax=NFmax, NFmin=NFmin)
        return self._GetParam('Irms', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm, **kwargs)

    def GetVrms(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return equivalent input-referred voltage noise Vrms.

        Vrms is computed as Irms / abs(gm). Units handling attempts to
        preserve caller-provided ``Units`` if present.
        """
        if 'Units' in kwargs:
            Units = kwargs['Units']
            kwargs['Units'] = 'A'
        else:
            Units = None
        Irms = self.GetIrms(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm, **kwargs)
        if Irms is None:
            return None
        gm = np.abs(self.GetGM(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm))
        PAR = (Irms/gm).transpose()
        if Units is not None:
            kwargs['Units'] = Units
        return self._FormatOutput(PAR, **kwargs)

    def _CheckFitting(self, FFmin, FFmax):
        """Update fitting frequency bounds and run ``FitNoise`` if needed."""
        if FFmin is not None or FFmax is not None:
            if self.FFmin != FFmin or self.FFmax != FFmax:
                # print ('Calc fitting')
                self.FFmin = FFmin
                self.FFmax = FFmax
                # if self.IsOK:
                self.FitNoise(Fmin=FFmin, Fmax=FFmax)

    def GetNoA(self, Vgs=None, Vds=None, Ud0Norm=False,
               FFmin=5, FFmax=7e3, **kwargs):
        """Return fitted flicker amplitude NoA for the requested grid.

        FFmin and FFmax specify the frequency range used for the
        fitting operation. The fitting is performed lazily using
        :meth:`_CheckFitting`.
        """
        self._CheckFitting(FFmin=FFmin,
                           FFmax=FFmax)
        return self._GetParam('NoA', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)

    def GetNoB(self, Vgs=None, Vds=None, Ud0Norm=False,
               FFmin=5, FFmax=7e3, **kwargs):
        """Return fitted flicker exponent NoB for the requested grid."""
        self._CheckFitting(FFmin, FFmax)
        return self._GetParam('NoB', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)

    def GetNoC(self, Vgs=None, Vds=None, Ud0Norm=False,
               FFmin=5, FFmax=7e3, **kwargs):
        """Return fitted thermal noise floor NoC for the requested grid."""
        self._CheckFitting(FFmin, FFmax)
        return self._GetParam('NoC', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)

    def GetNoAIds2(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return NoA normalized by Ids^2 for the requested grid."""
        NoA = self._GetParam('NoA', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)
        Ids = self.GetIds(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)
        return NoA/(Ids**2)

    def GetIrmsVds(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return Irms normalized by Vds (shorthand wrapper)."""
        return self._GetParam('Irms', Vgs=Vgs, Vds=Vds,
                              Ud0Norm=Ud0Norm, Normalize=True)

    def GetIrmsIds2(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return Irms normalized by Ids^2 for the requested grid."""
        Irms = self._GetParam('Irms', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)
        Ids = self.GetIds(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)
        return Irms/(Ids**2)

    def GetIrmsIds15(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return Irms normalized by Ids^1.5 for the requested grid."""
        Irms = self._GetParam('Irms', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)
        Ids = self.GetIds(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)
        return Irms/(Ids**1.5)

    def GetIrmsIds(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return Irms divided by Ids for the requested grid."""
        Irms = self._GetParam('Irms', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)
        Ids = self.GetIds(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm)
        return Irms/Ids

    def GetGmFcut(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return calculated gm cutoff frequency values for requested grid."""
        return self._GetParam('GmFcut', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm, **kwargs)

    def GetGmF0(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return low-frequency gm value (GmF0) for requested grid."""
        return self._GetParam('GmF0', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm, **kwargs)

    def GetGmErr(self, Vgs=None, Vds=None, Ud0Norm=False, **kwargs):
        """Return relative error between AC and DC gm for requested grid."""
        return self._GetParam('GmErr', Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm, **kwargs)
######################################################################################
######################################################################################
######################################################################################

class PyFETPlotDataClass(PlotDataClass.PyFETPlotBase):
    """Plot helper that knows how to draw PyGFETdb data objects.

    This small wrapper around :class:`PlotDataClass.PyFETPlotBase` adds
    domain-specific plotting conventions for GFET data: axis property
    mappings (AxsProp), color-coding options based on treatment
    metadata (ColorParams) and convenience plotting methods that take
    DataCharDC/DataCharAC instances as input (``Plot``,
    ``PlotDataSet``, ``PlotDataCh``).

    The methods expect objects produced by :class:`DataCharDC` and
    :class:`DataCharAC` and call their GetX methods to obtain the
    data arrays to plot.
    """

    # (logY, logX, X variable)
    AxsProp = {'Vrms': (1, 0, 'Vgs'),
               'Irms': (1, 0, 'Vgs'),
               'NoA': (1, 0, 'Vgs'),
               'FitErrA': (1, 0, 'Vgs'),
               'FitErrB': (1, 0, 'Vgs'),
               'NoB': (0, 0, 'Vgs'),
               'GM': (0, 0, 'Vgs'),
               'Ids': (0, 0, 'Vgs'),
               'Ig': (0, 0, 'Vgs'),
               'Rds': (0, 0, 'Vgs'),
               'FEMn': (0, 0, 'Vgs'),
               'FEMmu': (1, 0, 'Vgs'),
               'FEMmuGm': (1, 0, 'Vgs'),
               'PSD': (1, 1, 'Fpsd'),
               'GmMag': (1, 1, 'Fgm'),
               'GmPh': (0, 1, 'Fgm')}

    ColorParams = {'Contact': ('TrtTypes', 'Contact'),
                   'Length': ('TrtTypes', 'Length'),
                   'Width': ('TrtTypes', 'Width'),
                   'Pass': ('TrtTypes', 'Pass'),
                   'W/L': (None, None),
                   'Trt': (None, 'Name'),
                   'Date': (None, 'DateTime'),
                   'Ud0': (None, 'Ud0'),
                   'Device': ('TrtTypes', 'Devices.Name'),
                   'Wafer': ('TrtTypes', 'Wafers.Name')}  # TODO fix with arrays

    def __init__(self, Size=(9, 6)):
        self.CreateFigure(Size=Size)

    def GetColorValue(self, Data, ColorOn):
        """Return a value used to determine color mapping for plotting.

        Parameters
        ----------
        Data : DataCharDC or DataCharAC
            Data object providing the metadata fields referenced by
            ``ColorParams``.
        ColorOn : str
            Key from ``ColorParams`` indicating which metadata field
            to use for color selection.

        Returns
        -------
        object
            The metadata value used to choose the plotting color.
        """
        if self.ColorParams[ColorOn][1]:
            if self.ColorParams[ColorOn][0]:
                p = Data.__getattribute__(self.ColorParams[ColorOn][0])
                v = p[self.ColorParams[ColorOn][1]]
            else:
                v = Data.__getattribute__(self.ColorParams[ColorOn][1])
        elif ColorOn == 'W/L':
            p = Data.__getattribute__('TrtTypes')
            v = p['Width']/p['TrtTypes']['Length']
        return v

    def PlotDataCh(self, DataDict, Trts, Vgs=None, Vds=None, Ud0Norm=False,
                   PltIsOK=True, ColorOn='Trt'):
        """Plot channels grouped by treatment key.

        This convenience method iterates over the groups in ``DataDict``
        and calls :meth:`Plot` for each device while cycling colors.
        Any unhandled errors during plotting are caught and logged.
        """
        self.setNColors(len(DataDict))
        for Trtv in DataDict.values():
            self.color = self.NextColor()
            try:
                self.Plot(DataDict, Vgs=Vgs, Vds=Vds,
                          Ud0Norm=Ud0Norm, PltIsOK=PltIsOK)
            except:  # catch *all* exceptions
                print (sys.exc_info()[0])

    def PlotDataSet(self, DataDict, Trts=None,
                    Vgs=None, Vds=None, Ud0Norm=False,
                    PltIsOK=True, ColorOn='Trt', MarkOn='Cycle', **kwargs):
        """Plot a set of devices, color-coding by a chosen metadata field.

        Parameters
        ----------
        DataDict : dict
            Mapping from treatment/group names to lists of device data
            objects.
        Trts : sequence or None
            Order (or subset) of treatments to plot. If None all keys
            from DataDict are used.
        ColorOn : str
            Key indicating which metadata field to use for color
            mapping (must be in ``ColorParams``).
        MarkOn : str
            If set to 'Cycle' the marker style will advance per device.
        Additional kwargs are passed to
        """
        if Trts is None:
            Trts = DataDict.keys()

        Par = []
        for TrtN in sorted(Trts):
            for Dat in DataDict[TrtN]:
                Par.append(self.GetColorValue(Dat, ColorOn))

        Par = sorted(set(Par))
        self.setNColors(len(Par))
        ColPar = {}
        for p in Par:
            self.NextColor()
            ColPar[p] = self.color

        for TrtN in sorted(Trts):
            self.marks.reset()
            for Dat in DataDict[TrtN]:
                self.color = ColPar[self.GetColorValue(Dat, ColorOn)]
                if MarkOn == 'Cycle':
                    self.NextMark()                    

                try:
                    self.Plot(Dat, Vgs=Vgs, Vds=Vds,
                              Ud0Norm=Ud0Norm, PltIsOK=PltIsOK, **kwargs)
                except:  # catch *all* exceptions
                    print (TrtN, sys.exc_info()[0])

    def Plot(self, Data, Vgs=None, Vds=None,
             Ud0Norm=False, PltIsOK=True, ColorOnVgs=False, **kwargs):
        """Draw a single data object on all configured axes.

        For each axis configured in ``self.Axs`` this method queries the
        corresponding Data accessor (e.g. GetIds, GetGM) and plots the
        result. Axis scaling (log/linear) is applied according to
        ``AxsProp`` and optional noise-model overlays are drawn for the
        PSD axis.

        Parameters
        ----------
        Data : DataCharDC or DataCharAC
            Device data object to plot.
        Vgs, Vds, Ud0Norm : passed to the Data Get* accessors to select
            the plotted grid.
        PltIsOK : bool
            If True hide markers for devices where ``Data.IsOK`` is
            False by marking them with a '+' symbol.
        ColorOnVgs : bool
            Reserved for future use; currently ignored.
        Additional kwargs are forwarded to the Data Get* methods.
        """
        label = Data.Name

        for axn, ax in self.Axs.items():
            Mark = self.line + self.mark
            if not Data.IsOK and PltIsOK:
                Mark = '+'

            if self.AxsProp[axn][2] == 'Vgs':
                if Vgs is None:
                    Valx = Data.GetVgs(Ud0Norm=Ud0Norm)
                else:
                    Valx = Vgs
            else:
                func = Data.__getattribute__('Get' + self.AxsProp[axn][2])
                Valx = func(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm, **kwargs)

            func = Data.__getattribute__('Get' + axn)
            Valy = func(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm, **kwargs).flatten()

            if Valx is not None and Valy is not None:
                ax.plot(Valx, Valy, Mark, color=self.color, label=label)

                if axn == 'PSD':
                    a = Data.GetNoA(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm,
                                    **kwargs)
                    b = Data.GetNoB(Vgs=Vgs, Vds=Vds, Ud0Norm=Ud0Norm,
                                    **kwargs)
                    Valy = Fnoise(Valx, a, b).transpose()
                    ax.plot(Valx, Valy, Mark, '--',
                            color=self.color, alpha=0.5)

                if self.AxsProp[axn][0]:
                    ax.set_yscale('log')
                if self.AxsProp[axn][1]:
                    ax.set_xscale('log')
