kawin.precipitation

kawin.kawin.precipitation.KWNBase

PrecipitateBase(GenericModel)

class PrecipitateBase(self, matrix:MatrixParameters, precipitates:list[PrecipitateParameters], thermodynamics:GeneralThermodynamics, temperature:TemperatureParameters, constraints:Constraints=None):

Base class for precipitation models

The iteration method here may seem a bit odd, but it's mainly to reduce redundant calculations
For a model, we take that dX/dt = f(t, x), so only the time and current state is needed to compute dX/dt
While this is true for the KWN model, we need to perform quite a bit of calculations from x before being able to compute dX/dt
    This includes mass balance, nucleation rate and growth rate
For Euler:
    dX/dt is computed using the last state of the iteration, so in _calcDependentTerms, we just copy the last state in pData
For RK4:
    The first dX/dt is computed the same as for Euler
    Then for subsequent steps, we compute the new state using the current values of x (computed for each step of the iteration)
In both cases, after updating x, we compute the current state and append it to pData, which is then used for the next iteration

Parameters
----------
phases : list (optional)
    Precipitate phases (array of str)
    If only one phase is considered, the default is ['beta']
elements : list (optional)
    Solute elements in system
    Note: order of elements must correspond to order of elements set in Thermodynamics module
            Also, the list here should just be the solutes while the Thermodynamics module needs also the parent element
    If binary system, then default is ['solute']

def PrecipitateBase.cacheCalculations(self, useCache: bool = False):

def PrecipitateBase.phaseIndex(self, phase = None):

Returns index of phase in list

Parameters
----------
phase : str (optional)
    Precipitate phase (defaults to None, which will return 0)

def PrecipitateBase.reset(self):

Resets simulation results
This does not reset the model parameters, however, it will clear any stopping conditions

def PrecipitateBase._resetArrays(self):

Resets and initializes arrays for all variables
    time, temperature
    matrix composition, equilibrium composition (alpha and beta)
    driving force, impingement factor, nucleation barrier, critical radius, nucleation radius
    nucleation rate, precipitate density
    average radius, average aspect ratio, volume fraction

Extra variables include incubation offset and incubation sum

Time dependent variables will be set up as either
    (iterations)                     time, temperature
    (iterations, elements)           composition
    (iterations, phases, elements)   eq composition, total precipitate composition
    (iterations, phases)             Everything else
    This is intended for appending arrays to always be on the first axis

def PrecipitateBase.toDict(self):

Converts precipitation data to dictionary

def PrecipitateBase.fromDict(self, data):

Converts dictionary of data to precipitation data

def PrecipitateBase._appendArrays(self, newVals):

Appends new values to the variable list
NOTE: newVals must correspond to the same order as _packArrays with first axis as 1
    Ex rCrit is (n, phases) so corresponding new value should be (1, phases)
Since np append creates a new variable in memory, we have to reassign each term, then pack them into varList again
    TODO: it would be nice to reduce the number of times it copies, perhaps by preallocating some amount (say 1000)
            for each array and if we have not reached the end of the array, just stick the values at the latest index
            but once we reach the end of the array, we would append another 1000
            The after solving, we could clean up the arrays, or just use self.n to state where the end of the simulation is
I suppose we could make a list of str for each variable and call setattr

def PrecipitateBase.setConstraints(self, **kwargs):

Sets constraints

Possible constraints:
---------------------
minRadius - minimum radius to be considered a precipitate (1e-10 m)
maxTempChange - maximum temperature change before lookup table is updated (only for Euler in binary case) (1 K)

maxDTFraction - maximum time increment allowed as a fraction of total simulation time (0.1)
minDTFraction - minimum time increment allowed as a fraction of total simulation time (1e-5)

checkTemperature - checks max temperature change (True)
maxNonIsothermalDT - maximum time step when temperature is changing (1 second)

checkPSD - checks maximum growth rate for particle size distribution (True)
maxDissolution - maximum relative volume fraction of precipitates allowed to dissolve in a single time step (0.01)

checkRcrit - checks maximum change in critical radius (False)
maxRcritChange - maximum change in critical radius (as a fraction) per single time step (0.01)

checkNucleation - checks maximum change in nucleation rate (True)
maxNucleationRateChange - maximum change in nucleation rate (on log scale) per single time step (0.5)
minNucleationRate - minimum nucleation rate to be considered for checking time intervals (1e-5)

checkVolumePre - estimates maximum volume change (True)
checkVolumePost - checks maximum calculated volume change (True)
maxVolumeChange - maximum absolute value that volume fraction can change per single time step (0.001)

minNucleateDensity - minimum nucleate density to consider nucleation to have occurred (1e-5)
dtScale - scaling factor to attempt to progressively increase dt over time

def PrecipitateBase.setBetaBinary(self, functionType = 1):

Sets function for beta calculation in binary systems
    1 for implementation seen in Perez et al, 2008 (default)
    2 for implementation similar to multicomponent systems

If using a multicomponent system, the beta function defaults to the 2nd
    So this function will not do anything

Parameters
----------
functionType : int
    ID for function
        1 for implementation seen in Perez et al, 2008 (default)
        2 for implementation similar to multicomponent systems

def PrecipitateBase.particleGibbs(self, radius, phase = None):

Returns Gibbs Thomson contribution of a particle given its radius

Parameters
----------
radius : float or array
    Precipitate radius
phase : str (optional)
    Phase to consider (defaults to first precipitate in list)

def PrecipitateBase.addStoppingCondition(self, condition, mode = ‘or’):

Adds condition to stop simulation when condition is met

Parameters
----------
condition: PrecipitateStoppingCondition
mode: str
    'or' or 'and
    Conditions with 'or' will stop the simulation when at least one condition is met
    Conditions with 'and' will stop the simulation when all conditions are met

def PrecipitateBase.clearStoppingConditions(self):

Clears all stopping conditions

def PrecipitateBase.setup(self):

Sets up hidden parameters before solving
    Nucleation site density
    Grain boundary factors
    Strain energy

def PrecipitateBase.printHeader(self):

Overloads printHeader from GenericModel to do nothing
since status displays the necessary outputs

def PrecipitateBase.printStatus(self, iteration, modelTime, simTimeElapsed):

Prints various terms at latest step

Will print:
    Model time, simulation time, temperature, matrix composition
    For each phase
        Phase name, precipitate density, volume fraction, avg radius and driving force

def PrecipitateBase.preProcess(self):

Store array for non-derivative terms (which is everything except for the PBM models)

We use these terms for the first step of the iterators (for Euler, this is all the steps)
    For RK4, these terms will be recalculated in dXdt

def PrecipitateBase._calculateDependentTerms(self, t, x):

Gets all dependent terms (everything but PBM variables) that are needed to find dXdt

Steps:
    1. Mass balance
    2. Driving force - must be done after mass balance to get the current matrix composition
    3. Growth rate - must be done after driving force since dG is needed in multicomponent systems
    4. Nucleation rate
    5. Nucleate radius - must be done after nucleation rate since derived classes can change nucleation rate

For the first iteration, self._currY will be None from the preProcess function, in this case, we want
    to just grab the latest values to avoid double calculations

def PrecipitateBase.getdXdt(self, t, x):

Gets dXdt as a list for each phase
For the eulerian implementation, this is dn_i/dt for the bins in PBM for each phase

This is partially kind of dumb to have getdXdt and _getdXdt, however:
    getdXdt is to be compatible with GenericModel, which requires only x and t
    _getdXdt is to account for current PrecipitationData and growth rate as defined in this model

def PrecipitateBase._getdXdt(self, t, x, Y : PrecipitationData, growth):

def PrecipitateBase.correctdXdt(self, dt, x, dXdt):

def PrecipitateBase._correctdXdt(self, dt, x, dXdt, Y : PrecipitationData, growth):

def PrecipitateBase.postProcess(self, t, x):

1) Updates internal arrays with new values of t and x
2) Updates particle size distribution
3) Updates coupled models
4) Check stopping conditions
5) Return new values and whether to stop the model

def PrecipitateBase._processX(self, x):

def PrecipitateBase._calcMassBalance(self, t, x):

def PrecipitateBase._updateParticleSizeDistribution(self, t, x):

def PrecipitateBase._calcNucleationSites(self, t, x, p):

def PrecipitateBase._growthRate(self):

def PrecipitateBase._calcNucleationRate(self, t, x, Y : PrecipitationData):

kawin.kawin.precipitation.KWNEuler

PrecipitateModel (PrecipitateBase)

Euler implementation of KWN model

Parameters
----------
phases : list (optional)
    Precipitate phases (array of str)
    If only one phase is considered, the default is ['beta']
elements : list (optional)
    Solute elements in system
    Note: order of elements must correspond to order of elements set in Thermodynamics module
    If binary system, then defualt is ['solute']

def PrecipitateModel ._resetArrays(self):

Resets and initializes arrays for all variables

In addition to PrecipitateBase, the equilibrium aspect ratio area and population balance models are created here

def PrecipitateModel .reset(self):

Resets model results

def PrecipitateModel .toDict(self):

def PrecipitateModel .fromDict(self, data):

def PrecipitateModel .setPBMParameters(self, cMin = 1e-10, cMax = 1e-9, bins = 150, minBins = 100, maxBins = 200, adaptive = True, phase = None):

Sets population balance model parameters for each phase

Parameters
----------
cMin : float
    Minimum bin size
cMax : float
    Maximum bin size
bins : int
    Initial number of bins
minBins : int
    Minimum number of bins - will not be used if adaptive = False
maxBins : int
    Maximum number of bins - will not be used if adaptive = False
adaptive : bool
    Sets adaptive bin sizes - bins may still change upon nucleation
phase : str
    Phase to consider (will set all phases if phase = None or 'all')

def PrecipitateModel .setPSDrecording(self, record = True, phase = ‘all’):

Sets recording parameters for PSD of specified phase

Parameters
----------
record : bool (optional)
    Whether to record PSD, defaults to True
phase : str (optional)
    Precipitate phase to record for
    Defaults to 'all', which will apply to all precipitate phases

def PrecipitateModel .saveRecordedPSD(self, filename, phase = ‘all’):

Saves recorded PSD in npz format

Parameters
----------
filename : str
    File name to save to
    Note: the phase name will be added to the filename if all phases are being saved
compressed : bool (optional)
    Whether to save in compressed npz format
    Defualts to True
phase : str (optional)
    Phase to save PSD for
    Defaults to 'all', which will save a file for each phase

def PrecipitateModel .loadParticleSizeDistribution(self, data, phase = None):

Loads particle size distribution for specified phase

Parameters
----------
data : array
    Array of data containing precipitate sizes
phase : str (optional)
    Phase to consider (defaults to first precipitate in list)

def PrecipitateModel .particleRadius(self, phase = None):

Returns PSD bounds of given phase

Parameters
----------
phase : str (optional)
    Phase to consider (defaults to first precipitate in list)

def PrecipitateModel .particleGibbs(self, radius = None, phase = None):

Returns Gibbs Thomson contribution of a particle given its radius

Parameters
----------
radius : array (optional)
    Precipitate radaii (defaults to None, which will use boundaries
        of the size classes of the precipitate PSD)
phase : str (optional)
    Phase to consider (defaults to first precipitate in list)

def PrecipitateModel .getPBM(self, phase=None):

Returns population balance model of given phase

Parameters
----------
phase: str (optional)
    Phase to consider (defaults to first precipitate in list)

def PrecipitateModel ._createLookupBinary(self, T):

This creates a lookup table mapping the particle size classes to the interfacial composition

def PrecipitateModel ._setupAspectRatio(self):

def PrecipitateModel .setup(self):

Sets up additional variables in addition to PrecipitateBase

Sets up additional outputs, population balance models, equilibrium aspect ratio and equilibrium compositions

def PrecipitateModel ._interpolateAspectRatio(self, R, p):

Linear interpolation between self.eqAspectRatio and self.PBM[p].PSDbounds

Parameters
----------
R : float
    Equivalent spherical radius
p : int
    Phase index

def PrecipitateModel .getDt(self, dXdt):

The following checks are made
    1) change in number of particles moving between bins
        This is controlled by the implementation in PopulationBalanceModel,
        but essentially limits the number of particles moving between bins
    2) change in nucleation rate
        Time will be proportional to the 1/log(previous nuc rate / new nuc rate)
    3) change in temperature
        Limits how fast temperature can change
    4) change in critical radius
        Proportional to a percent change in critical radius
    5) estimated change in volume fraction
        Estimates the change in volume fraction from the nucleation rate and nucleation radius

def PrecipitateModel ._processX(self, x):

Quick check to make sure particles below the thresholds are 0
    RdrivingForceIndex - only for binary, where energy from the Gibbs-Thompson effect is high enough
        that the free energy of the precipitate is above the free energy surface of the matrix phase
        and equilibrium cannot be calculated
    minRadius - minimum radius to be considered a precipitate

def PrecipitateModel ._calcNucleationSites(self, t, x, p):

The _calcNucleationRate function in KWNBase calculates the nucleation rate as the
    probability that a site can form a nucleate that will continue to grow

To convert this probability to an actual nucleation rate, we multiply by the amount
    of available nucleation sites

The number of available sites is determined by:
    Available sites = All sites - used up sites + sites on parent precipitates
    The used up sites depends on the type of nucleation
        Bulk and grain corners - used sites = number of current precipitates
        Dislocation and grain edges - number of sites filled along the edges (assumes average radius of precipitates)
        Grain boundaries - number of sites filled along the faces (assumes average cross sectional area of precipitates)

def PrecipitateModel ._calcMassBalance(self, t, x, Y : PrecipitationData):

Mass balance to find matrix composition with new particle size distribution
This also includes: volume fraction, precipitate density, average radius, average aspect ratio and sum of precipitate composition

Notes on computing composition from mass balance
    Concentration of the precipitates - needed to get matrix composition

    For a line compound with composition x^beta, this boils down to:
    x_0 = (1-f_v) * x^inf + f_v * x^beta
        Where x_0 is initial composition, f_v is volume fraction and x^inf is matrix composition

    For non-stoichiometric compounds, we want to integrate the precipitate composition as a function of radius
        We'll call this term f_conc (fraction + concentration of precipitates), so:
        x_0 = (1-f_v) * x^inf + f_conc
    
    For infinite precipitate diffusion, the concentration of a single precipitate is assumed to be homogenous
    f_conc = r_vol * vol_factor * sum(n_i * R_i^3 * x_i^beta)
        Where r_vol is V^alpha / V^beta and vol_factor is a factor for converting R^3 to volume (for sphere, this is 4*pi/3)

    For no diffusion in precipitate, the concentration depends on the history of the precipitate compositions and growth rate
    We just have to convert the summation to an integral of the time derivative of the terms inside
    f_conc = r_vol * vol_factor * sum(int(d(n_i * R_i^3 * x_i^beta)/dt, dt))
    We'll assume x_i^beta is constant with time (for 3 or more components, this is not true, but assume it doesn't change significantly per iteration - it'll also be a lot harder to account for)
    d(f_conc)/dt = r_vol * vol_factor * sum(d(n_i)/dt * R_i^3 * x_i^beta + 3 * R_i^2 * d(R_i)/dt * n_i * x_i^beta)
        d(n_i)/dt is the change in precipitates, since we don't record this, this is just (x[p] - self.PBM[p].PSD) / dt - with x[p] being the new number density for phase p
        d(R_i)/dt is the growth rate, however, since we use a eulerian solver, this corresponds to the growth rate of the bins themselves, which is 0
            If we were to use a langrangian solver, then d(n_i)/dt would be 0 (since the density in each bin would be constant) and d(R_i)/dt would be the growth rate at R_i
    Then we can calculate f_conc per iteration as a time integration like we do with some of the other variables

def PrecipitateModel .getCurrentX(self):

Returns current value of time and X
In this case, X is the particle size distribution for each phase

def PrecipitateModel ._getdXdt(self, t, x, Y : PrecipitationData, growth):

Returns dn_i/dt for each PBM of each phase

def PrecipitateModel ._correctdXdt(self, dt, x, dXdt, Y : PrecipitationData, growth):

Corrects dXdt with the newly found dt, this adjusts the fluxes at the ends of the PBM so that we don't get negative bins

def PrecipitateModel ._growthRate(self, Y : PrecipitationData):

def PrecipitateModel ._singleGrowthBinary(self, p, Y : PrecipitationData):

Calculates growth rate for a single phase
This is separated from _growthRateBinary since it's used in _calculatePSD

Matrix/precipitate composition are not calculated here since it's
already calculated in _createLookupBinary

def PrecipitateModel ._growthRateBinary(self, Y : PrecipitationData):

Determines current growth rate of all particle size classes in a binary system

def PrecipitateModel ._singleGrowthMulti(self, p, Y : PrecipitationData):

Calculates growth rate for a single phase
This is separated from _growthRateMulti since it's used in _calculatePSD

This will also calculate the matrix/precipitate composition 
for the radius in the PSD as well as equilibrium (infinite radius)

def PrecipitateModel ._growthRateMulti(self, Y : PrecipitationData):

Determines current growth rate of all particle size classes in a multicomponent system

def PrecipitateModel ._updateParticleSizeDistribution(self, t, x):

Updates particle size distribution with new x

Steps:
    1. Check if growth rate calculation failed with negative driving force
        We'll reset the PBM since we can't do much from here, but the chances of this happening should be pretty low
    2. Update the PBM with new x
    3. Check if the PBM needs to adjust the size class
        If so, then update the cached aspect ratio and precipitate composition with the new size classes
    4. Remove precipitates below a certain threshold (RdrivingForceIndex and minRadius)
    5. Calculate the dissolution index (index at which below are not considered when calculating dt)
        This is to prevent very small dt as the growth rate increases rapidly when R->0

kawin.kawin.precipitation.PrecipitationParameters

PrecipitationData

class PrecipitationData(self, phases: list[str], elements: list[int], N: int = 1):

def PrecipitationData.reset(self, N: int = 1):

def PrecipitationData.appendToArrays(self, newData):

Appends data from another PrecipitationData object to current one

def PrecipitationData.copySlice(self, N: int = 0):

def PrecipitationData.setSlice(self, sliceData, N: int = 0):

def PrecipitationData.print(self, N: int = 0):

def PrecipitationData.toDict(self):

def PrecipitationData.fromDict(self, data):

TemperatureParameters

class TemperatureParameters(self, *args):

def TemperatureParameters.setIsothermalTemperature(self, T: float):

def TemperatureParameters.setTemperatureArray(self, times: list[float], temperatures: list[float]):

def TemperatureParameters.setTemperatureFunction(self, func):

def TemperatureParameters.call(self, t):

MatrixParameters

class MatrixParameters(self, solutes):

def MatrixParameters.initComposition(self):

def MatrixParameters.initComposition(self, value):

def MatrixParameters.update(self):

PrecipitateParameters

class PrecipitateParameters(self, name, phase = None):

Parameters for a single precipitate

def PrecipitateParameters.gamma(self):

def PrecipitateParameters.gamma(self, value):

def PrecipitateParameters.validate(self):

def PrecipitateParameters.computeStrainEnergyFromR(self, r):

def PrecipitateParameters.computeGibbsThomsonContribution(self, r):

Constraints

class Constraints(self):

def Constraints.reset(self):

def Constraints.computeDTfromPSD(self, n, temperatures, PBMs, growth, dissolutionIndex, phases, dtMax):

def Constraints.computeDTfromNucleationRate(self, n, nucRate, phases, dtPrev, dtMax):

def Constraints.computeDTfromTemperature(self, n, temperatures, dtPrev, dtMax):

def Constraints.computeDTfromRcrit(self, n, Rcrit, dGs, phases, dtPrev, dtMax):

def Constraints.computeDTfromVolume(self, n, nucRate, nucRadius, PBMs, growths, VmAlpha, VmBeta, GB, phases, dtMax):

kawin.kawin.precipitation.PopulationBalance

PopulationBalanceModel

class PopulationBalanceModel(self, cMin = 1e-10, cMax = 1e-9, bins = 150, minBins = 100, maxBins = 200, record=False):

Class for handling particle size distributions (PSD)
    This include time evolution, moments and graphing

NOTE: more formally, the PSD is defined such that the number of particles of size R in a volume
    is the integral of the PSD from R-1/2 to R+1/2
    For the discrete PSD, n_i = PSD_i * (R+1/2 - R-1/2)
    Here, we just store the PSD as the number of particles (n_i), but the formulation for
    growth rates and moments are the same since the factor (R+1/2 - R-1/2) cancels out

Parameters
----------
cMin : float (optional)
    Lower bound of PSD (defaults at 1e-10)
cMax : float (optional)
    Upper bound of PSD (defaults at 1e-9)
bins : int (optional)
    Initial number of bins (defaults at 150)
minBins : int (optional)
    Minimum number of bins over an order of magnitude (defaults at 100)
maxBins : int (optional)
    Maximum number of bins over an order of magnitude (defaults at 200)

Attributes
----------
originalMin : float
    Minimum bin size initially set
min : float
    Current minimum bin size (this will almost always be equal to originalMin)
originalMax : float
    Maximum bin size initially set
max : float
    Current maximum bin size
bins : int
    Default number of bins
minBins : int
    Minimum number of allowed bins
maxBins : int
    Maximum number of allowed bins
PSD : array
    Particle size distribution
PSDsize : array
    Average radius of each PSD size class
PSDbounds : array
    Radius at the bounds of each size class - length of array is len(PSD)+1

def PopulationBalanceModel.reset(self, resetBounds = True):

Resets the PSD to 0 and resets bin size and number of bins to original values
This will remove any size classes that were added since initialization

def PopulationBalanceModel.enableRecording(self):

Enables recording of particle size distribution per iteration

The initial data in the recorded bin is t = 0, N_i = 0

The size of the recorded particle size distribution will be (n x max bins)
    Where n in the number of iterations
    max bins is the maximum number of bins, if the current number is smaller, the rest of the array will be 0

def PopulationBalanceModel.resetRecordedData(self):

If recording, then reset the recorded bins to the original size (starting with t = 0, N_i = 0)
If not recording, then clear the recorded data

def PopulationBalanceModel.disableRecording(self):

Disables recording

We won't clear the recorded bins here in case the user still wants to grab recorded data

def PopulationBalanceModel.setRecording(self, record = True):

Wrapper around enable and disable recording

def PopulationBalanceModel.removeRecordedData(self):

Removes recorded data

def PopulationBalanceModel.record(self, time):

Adds current PSD data to recorded arrays

TODO: Make sure this works when adaptive bins is False

def PopulationBalanceModel.saveRecordedPSD(self, filename: str | Path):

Saves recorded data into npz format

Note: If recording is disabled, then this function will do nothing since
      there is nothing to save anyways

Parameters
----------
filename : str
    File name to save to

def PopulationBalanceModel.loadRecordedPSD(self, filename: str | Path):

Loads recorded PSD

def PopulationBalanceModel._grabPSDfromIndex(self, index):

Returns PSD bounds, PSD bins and PSD from recorded data based off index

Since the number of bins is likely less than the max, we want to grab only the non-zero indices
TODO: two concerns
    1) this may remove the last 1 bins (this may be okay since we add new bins once the
        list bins has at least 1 particle), so the last bin would be 0 anyways

def PopulationBalanceModel._interpolatePSD(self, psd, psdSize, psdBounds, psdBoundsOut, psdSizeOut):

def PopulationBalanceModel.setPSDtoRecordedTime(self, time):

Sets particle size distribution to specific time if recorded

Parameter
---------
time : float
    Time to load PSD from, will load to nearest time available

def PopulationBalanceModel.setAdaptiveBinSize(self, adaptive):

For Euler implementation, sets whether to change the bin size when
the number of filled bins > maxBins or < minBins

If False, the bins will still be if nucleated particles are greater than the max bin size
and bins will still be added when the last bins starts to fill (but this will not change the bin size)

def PopulationBalanceModel.setBinConstraints(self, bins = 150, minBins = 100, maxBins = 200):

Sets constraints for minimum and maxinum number of bins over an order of magnitude

All bins will be overridden to an even number
Minimum number of bins will be overridden to be at most half of maximum bins
Default bins will be overridden to be halfway between min and max bins if out of range

def PopulationBalanceModel.loadDistribution(self, data):

Creates a particle size distribution from a set of data

Parameters
----------
data : array of floats
    Array of data to be inserted into PSD

def PopulationBalanceModel.loadDistributionFunction(self, function):

Creates a particle size distribution from a function

Parameters
----------
function : function
    Takes in R and returns density

def PopulationBalanceModel.createBackup(self):

Stores current PSD and PSDbounds

def PopulationBalanceModel.revert(self):

Reverts to previous PSD and PSDbounds

NOTE: this appears to be unused
    (this was used in the previous KWNEuler implementation when the PSD could change within an iteration)
    (now it changes between iterations, so we don't need to revert back if something goes wrong)

def PopulationBalanceModel.changeSizeClasses(self, cMin, cMax, bins = None, resetPSD = False):

Changes the size classes and resets the PSD

This is done by linear interpolation of the previous bins and PSD
And interpolating to the new bins and PSD
Due to differences in bin size (thus resolution of the PSD), the number density
    could be a little different. To correct for this, we get the 3rd moment of the
    previous PSD and the new PSD, and correct the new PSD to have the same 3rd moment

Parameters
----------
cMin : float
    Lower bound of PSD
cMax : float
    Upper bound of PSD
bins : int
    Number of bins
resetPSD : bool (optional)
    Whether to reset the PSD (defaults to False)

def PopulationBalanceModel.addSizeClasses(self, bins = 1):

Adds an additional number of size classes to end of distribution

Parameters
----------
bins : int
    Number of bins to add

def PopulationBalanceModel.adjustSizeClassesEuler(self, checkDissolution = False):

1) adds some bins to the end of the PSD if the last bin has at least 1 precipitate
    Number of bins is 1/4 of the original number of bins
2) If adaptive bin size is enabled, then two checks
    2a) if number of bins > max bins, then resize to have the number of bins be the minimum
    2b) if checking dissolution and number of filled bins < 1/2 min bins,
        then resize to last filled bin with the number of bins being the maximum

Parameters
----------
checkDissolution : bool
    Whether to check if the PSD is getting smaller and resize accordingly

Returns
-------
change : bool
    When the number of bins changed
newIndices : int or None
    The number of bins added to the PSD
    If the size of the bins changed, then this is None to indicate that resizing occured

def PopulationBalanceModel.normalize(self):

Normalizes the PSD to 1

def PopulationBalanceModel.normalizeToMoment(self, order = 0):

Normalizes the PSD with so that the moment of specified order will be 1

Parameters
----------
order : int (optional)
    Order of moment that PSD will be normalized to (defaults to 0)
    Using zeroth order will normalize the PSD so the sum of PSD will be 1

def PopulationBalanceModel.getDissolutionIndex(self, maxDissolution, minIndex = 0):

Finds indices when the volume fraction of particles below this index is
within the maximum amount (fraction-wise) that the PSD is allowed to dissolve

So find R_max where int(0, R_max, R^3 * dr) < maxDissolution * int(0, infinity, R^3 * dr)
The index is the correspoinding index to R_max

Parameters
----------
maxDissolution : float
    Max fraction allowed to dissolve
minIndex : int
    Minimum index which below, all particles are allowed to dissolve
    Upper limit on dissolution index

Returns
-------
max of [dissolution index, minIndex]

def PopulationBalanceModel.getDTEuler(self, currDT, growth, dissolutionIndex, maxBinRatio = 0.4):

Calculates time interval for Euler implementation
    dt < dR / (2 * growth rate)
This ensures that at most, only half of particles in one size class can go to another

Also finds dt such that the max delta in growth rate is 0.4 dR
    We could use 0.5 dR which is the upper limit
        (for a given bin, the max change in density would remove all particles, with 0.5 getting smaller and 0.5 getting bigger)
    But 0.4 dR should be slightly more stable

TODO: allow variable ratio - this will make it more flexible for testing different time step constraints

Parameters
----------
currDT : float
    Current time interval, will be returned if it's smaller than what's given by the contraint
growth : array of floats
    Growth rate, must have lenth of bins+1 (or length of PSDbounds)
maxDissolution : float
    Maximum volume allowed to dissolve
startIndex : int
    First index to look at for growth rate, all indices below startIndex will be ignored
maxBinRatio : float (optional)
    Max ratio of particles in bin allowed to move to a nearby bin
    Default is 0.4

def PopulationBalanceModel.getdXdtEuler(self, flux, nucRate, nucRadius, psd):

dn_i/dt = d(G*n)/dr + nucRate

d(G*n)/dr is calculated from two conditions
For positive growth rates - d(G*n)/dr|_i = n_i * flux_i / dr
For negative growth rates - d(G*n)/dr|_i = n_(i-1) * flux_i / dr
TODO : check that the two equations above represent the implementation

Parameters
----------
flux : numpy array (bins+1)
    Growth rate of particles in m/s
nucRate : float
    Nucleation rate in #/m^3/s
nucRadius : float
    Nucleation radius in m
psd : numpy array (bins)
    Particle size distribution with number density #/m3

Returns
-------
dXdt (bins) - corresponds to dn_i/dt

def PopulationBalanceModel.correctdXdtEuler(self, dt, flux, nucRate, nucRadius, psd):

Given dt, correct the net flux so PSD will not be negative
    Essentially, the total number of particles leaving a bin should be less than or equal to the number of particles in the bin

Size of fluxes is bins+1 while for PSD, it is bins

For fluxes on the right (positive) side of the PSD
    We limit fluxes so that J_i+1 * dt < PSD_i

For fluxes on the left (negative) side of the PSD
    We limit fluxes so that -J_i * dt < PSD_i

Normally, this wouldn't be an issue since the time step from getDtEuler would limit J_i*dt to less than half the number of particles in a bin
    But because we set a dissolution threshold to ignore (to prevent extremely small dt since growth rate scales by 1/r), the bins below
    the dissolution threshold will not follow the constraint we apply in getDtEuler

Parameters
----------
dt : float
    time step
flux : numpy array (bins+1)
    Growth rate of particles in m/s
nucRate : float
    Nucleation rate in #/m^3/s
nucRadius : float
    Nucleation radius in m
psd : numpy array (bins)
    Particle size distribution with number density #/m3

Returns
-------
dXdt (bins) - corresponds to dn_i/dt corrected to avoid negative bins

def PopulationBalanceModel.updatePBMEuler(self, time, newN):

Updates PBM with new values

Parameters
----------
time : float
    New time
newN : numpy array
    New number density

def PopulationBalanceModel.moment(self, order, weights=None, N=None):

Given arbtrary PSD, return moment

Parameters
----------
order: float
    Moment order
weights: np.array (optional)
    Weights for each bin (must have length (bins,))
    If None, all bins are weighted equally
N: np.array (optional)
    PSD / number density (must have length (bins,))
    If None, then internal PSD will be used

def PopulationBalanceModel.cumulativeMoment(self, order, N=None, weights=None):

Given arbtrary PSD, return cumulative moment (from 0 to max)

Parameters
----------
order: float
    Moment order
N: np.array (optional)
    PSD / number density (must have length (bins,))
    If None, then internal PSD will be used
weights: np.array (optional)
    Weights for each bin (must have length (bins,))
    If None, all bins are weighted equally

def PopulationBalanceModel.zeroMoment(self, N=None, weights=None):

Sum of N

def PopulationBalanceModel.firstMoment(self, N=None, weights=None):

Length weighted moment of N

def PopulationBalanceModel.secondMoment(self, N=None, weights=None):

Area weighted moment of N

def PopulationBalanceModel.thirdMoment(self, N=None, weights=None):

Volume weighted moment of N

def _set_xlim(pbm: PopulationBalanceModel, x, ax):

def plotPSD(pbm: PopulationBalanceModel, scale=1, fill=False, ax=None, *args, **kwargs):

Plots the N for each bin as a curve

Parameters
----------
pbm: PopulationBalanceModel
scale : float (optional)
    Scale factor for x-axis (defaults to 1)
    Note: this is for grain boundary nucleation where the
        reported precipitate radius differs from the radius
        determined by precipitate curvature
fill : bool (optional)
    Will fill area between PSD curve and x-axis (defaults to False)
ax: matplotlib Axes (optional)
*args, **kwargs - extra arguments for plotting

def plotPDF(pbm: PopulationBalanceModel, scale=1, fill=False, ax=None, *args, **kwargs):

Plots the distribution density as a curve
Defined as f_i = N_i / (R_i+1/2 - R_i-1/2)
    This is such that N = \int(f dR)

Parameters
----------
pbm: PopulationBalanceModel
scale : float (optional)
    Scale factor for x-axis (defaults to 1)
    Note: this is for grain boundary nucleation where the
        reported precipitate radius differs from the radius
        determined by precipitate curvature
fill : bool (optional)
    Will fill area between PSD curve and x-axis (defaults to False)
ax: matplotlib Axes (optional)
*args, **kwargs - extra arguments for plotting

def plotCDF(pbm: PopulationBalanceModel, scale=1, order=0, ax=None, *args, **kwargs):

Plots cumulative size distribution density

Parameters
----------
axes : Axes
    Axis to plot on
scale : float (optional)
    Scale factor for x-axis (defaults to 1)
    Note: this is for grain boundary nucleation where the
        reported precipitate radius differs from the radius
        determined by precipitate curvature
order : int (optional)
    Moment of specified order
*args, **kwargs - extra arguments for plotting