import warnings
from typing import TYPE_CHECKING, Type

import numpy as np

NUMERICAL_ZERO = 1e-20

# can add additional solvers to have more options but need to implement NetworkFlow wrapper
S_CPLEX_QUAD = "cplex-quad"
S_CPLEX_LINEAR = "cplex-linear"
S_GLPK = "glpk-linear"

_SOLVER_PREFERENCE = (
    S_GLPK,
    S_CPLEX_LINEAR,
    S_CPLEX_QUAD,
)

QUADRATIC = {
    S_CPLEX_QUAD: True,
    S_CPLEX_LINEAR: False,
    S_GLPK: False,
}

if TYPE_CHECKING:
    from wholecell.utils._netflow._base import NetworkFlowProblemBase

SOLVERS: dict[str, "Type[NetworkFlowProblemBase]"] = {}
try:
    from ._netflow.nf_cplex import NetworkFlowCPLEX
except ImportError:
    # NOTE: nf_cplex assumes callers catch the ImportError if IBM CPLEX is not
    # installed. To use it, install the CPLEX binary library from IBM (it's
    # free for students) and do `pip install cplex>=12.8.0.0`.
    pass
else:
    SOLVERS[S_CPLEX_QUAD] = NetworkFlowCPLEX
    SOLVERS[S_CPLEX_LINEAR] = NetworkFlowCPLEX

try:
    from ._netflow.nf_glpk import NetworkFlowGLPK
except ImportError:
    pass
else:
    SOLVERS[S_GLPK] = NetworkFlowGLPK

if not SOLVERS:
    raise Exception("No solvers available.")

for solver in _SOLVER_PREFERENCE:
    if solver in SOLVERS:
        DEFAULT_SOLVER = solver
        break
else:
    raise Exception("Could not choose a default solver.")

# Errors


class FBAError(Exception):
    pass


class AlreadyExistsError(FBAError):
    pass


class DoesNotExistError(FBAError):
    pass


class InvalidBoundaryError(FBAError):
    pass


class SolverUnavailableError(FBAError):
    pass


# Classes


# noinspection PyArgumentList
class FluxBalanceAnalysis:
    """FluxBalanceAnalysis
    Solver for various FBA implementations.
    Required arguments:
    - reactionStoich, a dict of strings:dicts (reactionID:reaction stoich)
            Each value in the dict is a dict of molecule ID to stoichiometry pairs
    - externalExchangedMolecules, an iterable of strings (moleculeIDs)
            Every provided ID will be set up with an exchange flux.
    - objective, a dict of strings:floats (moleculeID:objective value)
            The meaning and usage of the objective will vary depending on the
            formulation of FBA desired.
    Optional arguments (set to None for default behavior):
    - objectiveType, a string
            "standard": standard FBA objective (default)
            "flexible": flexFBA
            "homeostatic": similar to FBA; optimizes towards desired homeostatic concentrations
            "range_homeostatic": homeostatic FBA with the option to allow a range of desired homeostatic concentrations
            "kinetic_only":  optimize to minimize deviation from reaction flux through given kinetic reaction flux targets
            "homeostatic_kinetics_mixed":  A split objective, both range_homeostatic and kinetic, optimizing to minimize deviation
                                                                       from homeostatic concentrations for a set of molecules, and a set of kinetic rates for a
                                                                       set of reaction fluxes.
    - objectiveParameters, a dict
            Keys are specific to the FBA implementation.
    - internalExchangedMolecules, an iterable of strings (moleculeIDs)
            Every provided ID will be set up with an exchange flux.
    - reversibleReactions, a list of strings (reactionIDs for reversible reactions)
    - moleculeMasses, a dict of floats (moleculeID:mass)
            Used in computing the net mass into the system.  Only needed and used
            for moleculeIDs in externalExchangedMolecules.
    Caveats:
    There is no strict type checking, despite what the above may imply.
    During initialization, an exception will be raised if a reference is made
    to an unutilized metabolite etc as described by the reaction
    network.
    """

    # Format strings for autogenerated IDs

    ## Reverse reactions
    _generatedID_reverseReaction = "{} (reverse)"

    ## Exchange fluxes
    _generatedID_externalExchange = "external exchange - "
    _generatedID_internalExchange = "internal exchange - "

    _generatedID_externalExchangePos = "external exchange pos - "
    _generatedID_externalExchangeNeg = "external exchange neg - "
    _generatedID_externalExchangeAbs = "external exchange abs - "

    ## Objective
    _generatedID_moleculesToEquivalents = "molecules of to fractional objective equivalents - "
    _generatedID_moleculeEquivalents = "fractional objective equivalent for molecules of - "

    ## Flex FBA
    _generatedID_fractionalDifferenceLeading = (
        "difference between fractional objective equivalents of leading molecule and - "
    )
    _generatedID_fractionalDifferenceBiomass = (
        "difference between fractional objective equivalents of and biomass objective - "
    )

    _generatedID_fractionsOut = "fractional objective equivalents of out - "
    _generatedID_fractionalDifferenceLeadingOut = (
        "difference between fractional objective equivalents of leading molecule and out - "
    )
    _generatedID_fractionalDifferenceBiomassOut = (
        "difference between fractional objective equivalents of and biomass objective out - "
    )

    ## Homeostatic FBA
    _generatedID_fractionBelowUnityOut = "fraction below unity, out - "
    _generatedID_fractionAboveUnityOut = "fraction above unity, out - "
    _generatedID_quadFractionFromUnity = "fraction off from unity - "

    _standardObjectiveReactionName = "Standard biomass objective reaction"

    _massExchangeID = "Mass Exchange"
    _massExchangeOutName = "Mass exchange out"

    _forcedUnityColName = "Column forced at unity"

    _pseudometaboliteGAM = "GAM reaction pseudometabolite"
    _reactionID_GAM = "Growth-associated maintenance reaction"
    _reactionID_NGAM = "Non-growth-associated maintenance reaction"
    _reactionID_polypeptideElongationEnergy = "PolypeptideElongation energy reaction"

    ## Range Homeostatic FBA
    _generatedID_fractionInRangeOut = "fraction within target range, out - "

    ## MOMA
    _generatedID_amountOver = "Amount flux is over target - "
    _generatedID_amountUnder = "Amount flux is under target - "
    _generatedID_quadFluxRelax = "Amount flux differs from target - "

    _generatedID_kineticReactionEquivalentsPseudoflux = (
        "pseudoflux to remove kinetic objective equivalents for reaction - "
    )
    _generatedID_kineticReactionEquivalents = "kinetic reaction objective equivalent for reaction - "

    ## Kinetic
    _generatedID_reactionFluxEquivalents = "reaction flux equivalent for reaction - "
    _generatedID_conversionFlux = "Flux converting flux to kinetic objective equivalents - "

    ## Kinetic or homeostatic
    _generatedID_low_target_range = "objective pseudoflux in range below average - "
    _generatedID_high_target_range = "objective pseudoflux in range above average - "
    _generatedID_target_range = "objective pseudoflux in range - "

    # Initialization

    def __init__(
        self,
        reactionStoich,
        externalExchangedMolecules,
        objective,
        objectiveType=None,
        objectiveParameters=None,
        internalExchangedMolecules=None,
        secretionPenaltyCoeff=None,
        moleculeMasses=None,
        maintenanceCostGAM=None,
        maintenanceReaction=None,
        solver=DEFAULT_SOLVER,
    ):
        if solver not in SOLVERS:
            raise SolverUnavailableError(f"Unrecognized or unavailable solver: {solver}")

        # Set objective type
        if objectiveType is None:
            self.objectiveType = "standard"
        else:
            self.objectiveType = objectiveType

        # Set objective weights
        if objectiveParameters is not None:
            self.kineticObjectiveWeight = objectiveParameters.get("kineticObjectiveWeight", 0)
            self.kinetic_objective_weight_in_range = (
                objectiveParameters.get("kinetic_objective_weight_in_range", 0) * self.kineticObjectiveWeight
            )
        else:
            self.kineticObjectiveWeight = 0
            self.kinetic_objective_weight_in_range = 0

        if self.kineticObjectiveWeight > 1 or self.kineticObjectiveWeight < 0:
            raise FBAError(
                "kineticObjectiveWeight must be between 0 and 1 inclusive."
                + " It represents the percentage of preference going to kinetics."
            )
        if self.kinetic_objective_weight_in_range < 0:
            raise FBAError("kinetic_objective_weight_in_range must be non-negative.")

        self.homeostaticObjectiveWeight = 1 - self.kineticObjectiveWeight
        self._kinetic_objective_scaling = 1.0  # Value to scale by time step

        # Set solver
        self._solver = SOLVERS[solver](QUADRATIC[solver])

        self._forceInternalExchange = False

        # Output calculations
        self._outputMoleculeIDs = {}
        self._outputMoleculeCoeffs = []

        self.reactionStoich = reactionStoich.copy()

        # Keep track of non-standard reactions
        self._specialFluxIDsSet = set()
        self._kineticTargetFluxes = set()
        self._oneSidedReactions = set()
        self._active_kinetic_targets = set()

        # Keep track of homeostatic targets, empty if not used
        self._homeostaticTargetMolecules = set()

        # Call indivdual initialization methods
        self._initReactionNetwork(self.reactionStoich)
        self._initExternalExchange(externalExchangedMolecules)

        if self.objectiveType == "standard":
            self._initObjectiveEquivalents(objective)
            self._initObjectiveStandard(objective)

        elif self.objectiveType == "flexible":
            self._initObjectiveEquivalents(objective)
            self._initObjectiveFlexible(objective, objectiveParameters)

        elif self.objectiveType == "homeostatic":
            self._initObjectiveEquivalents(objective)
            self._initObjectiveHomeostatic(objective)

            if internalExchangedMolecules is not None:
                raise FBAError(
                    'Internal exchange molecules are automatically defined when using self.objectiveType = "homeostatic"'
                )
            internalExchangedMolecules = sorted(objective.keys())

        elif self.objectiveType == "range_homeostatic":
            self._initObjectiveEquivalents(objective)
            self._initObjectiveHomeostatic(objective)
            self._initObjectiveRangeHomeostatic(objective, objectiveParameters)

            if internalExchangedMolecules is not None:
                raise FBAError(
                    'Internal exchange molecules are automatically defined when using self.objectiveType = "range_homeostatic"'
                )
            internalExchangedMolecules = sorted(objective.keys())

        elif self.objectiveType == "kinetic_only":
            if "reactionRateTargets" in objectiveParameters:
                rateObjective = objectiveParameters["reactionRateTargets"].copy()
            else:
                raise FBAError(
                    "When using homeostatic_kinetics_mixed objective, a reactionRateTargets dict of reactionName:target rate must be provided in objectiveParameters."
                )

            self._initObjectiveKinetic(rateObjective, objectiveParameters)

        elif self.objectiveType == "homeostatic_kinetics_mixed":
            # Set up homeostatic objective
            self._initObjectiveEquivalents(objective)
            self._initObjectiveHomeostatic(objective)

            if internalExchangedMolecules is not None:
                raise FBAError(
                    'Internal exchange molecules are automatically defined when using self.objectiveType = "homeostatic_kinetics_mixed"'
                )
            internalExchangedMolecules = sorted(objective.keys())

            # Set up kinetic objective
            if "reactionRateTargets" in objectiveParameters:
                rateObjective = objectiveParameters["reactionRateTargets"].copy()
            else:
                raise FBAError(
                    "When using homeostatic_kinetics_mixed objective, a reactionRateTargets dict of reactionName:target rate must be provided in objectiveParameters."
                )

            self._initObjectiveKinetic(rateObjective, objectiveParameters)

        else:
            raise FBAError(f"Unrecognized self.objectiveType: {self.objectiveType}")

        self._initInternalExchange(internalExchangedMolecules)

        self._initExchangeMass(externalExchangedMolecules, moleculeMasses, secretionPenaltyCoeff)

        self._initMaintenance(maintenanceCostGAM, maintenanceReaction)

        # Set up values that will change between runs

        self.setExternalMoleculeLevels(0)
        self.setInternalMoleculeLevels(0)

        self._buildEqConst()

    def _initReactionNetwork(self, reactionStoich):
        """Create the reaction network, initializing molecules and biochemical
        reactions."""

        reactionIDs = []

        for reactionID in sorted(reactionStoich):
            stoichiometry = reactionStoich[reactionID]
            for moleculeID, stoichCoeff in stoichiometry.items():
                self._solver.setFlowMaterialCoeff(reactionID, moleculeID, stoichCoeff)

            reactionIDs.append(reactionID)

        self._reactionIDs = tuple(reactionIDs)
        self._reactionIDsSet = set(reactionIDs)

    def _initExternalExchange(self, externalExchangedMolecules):
        """Create external (media) exchange reactions."""

        externalMoleculeIDs = []
        externalExchangeIDs = []

        for moleculeID in externalExchangedMolecules:
            exchangeID = self._generatedID_externalExchange + moleculeID

            if moleculeID in externalMoleculeIDs:
                print("Warning: attempting to set multiple external exchanges" + f" for {moleculeID}. Skipping...")
                continue

            # NOTE: The convention, if I am not mistaken, is to define
            # exchanges as pointing out of the system, even though it is common
            # to think of exchanges as inputs.  Regardless this choice should
            # have no impact outside of this class.

            self._solver.setFlowMaterialCoeff(exchangeID, moleculeID, -1)

            externalMoleculeIDs.append(moleculeID)
            externalExchangeIDs.append(exchangeID)
            self._specialFluxIDsSet.add(exchangeID)

        self._externalMoleculeIDs = tuple(externalMoleculeIDs)
        self._externalExchangeIDs = tuple(externalExchangeIDs)

    def _initObjectiveEquivalents(self, objective, pseudoFluxFormat=None, objectiveEquivFormat=None):
        """Create pseudo-reactions that convert molecules into their fractional
        objective equivalents.  The objectiveType determines how these
        fractions are used."""

        # Use default format strings if none provided
        if pseudoFluxFormat is None:
            pseudoFluxFormat = self._generatedID_moleculesToEquivalents
        if objectiveEquivFormat is None:
            objectiveEquivFormat = self._generatedID_moleculeEquivalents

        for moleculeID in sorted(objective):
            coeff = objective[moleculeID]
            pseudoFluxID = pseudoFluxFormat + moleculeID
            objectiveEquivID = objectiveEquivFormat + moleculeID

            self._solver.setFlowMaterialCoeff(pseudoFluxID, moleculeID, -coeff)

            self._solver.setFlowMaterialCoeff(pseudoFluxID, objectiveEquivID, +1)

            # Objective molecules are output molecules
            if moleculeID not in self._outputMoleculeIDs:
                self._outputMoleculeIDs[moleculeID] = len(self._outputMoleculeIDs)
                self._outputMoleculeCoeffs.append(dict())
            i = self._outputMoleculeIDs[moleculeID]
            self._outputMoleculeCoeffs[i][pseudoFluxID] = -coeff

    def _initObjectiveStandard(self, objective):
        """Create the pseudo-reaction for the standard biomass objective.  In
        the standard objective, all molecules must be created/destroyed in
        prescribed ratios."""

        for moleculeID in objective:
            objectiveEquivID = self._generatedID_moleculeEquivalents + moleculeID

            self._solver.setFlowMaterialCoeff(self._standardObjectiveReactionName, objectiveEquivID, -1)

        self._solver.setFlowObjectiveCoeff(self._standardObjectiveReactionName, +1)

    def _initObjectiveFlexible(self, objective, objectiveParameters):
        """Create the abstractions needed for the flexFBA objective.  In brief,
        flexFBA permits partial biomass objective satisfaction for individual
        molecules if network disruptions inhibit molecule production."""

        # Load parameters
        leadingMoleculeID = objectiveParameters["leading molecule ID"]

        if leadingMoleculeID not in objective:
            raise FBAError("flexFBA leading molecule must be in the objective")

        fractionalDifferenceWeight = objectiveParameters["gamma"]

        if fractionalDifferenceWeight < 0:
            raise FBAError("flexFBA gamma parameter must be nonnegative")

        biomassSatisfactionWeight = objectiveParameters["beta"]

        if biomassSatisfactionWeight < 0:
            raise FBAError("flexFBA beta parameter must be nonnegative")

        if any(coeff < 0 for coeff in objective.values()):
            warnings.warn("flexFBA is not designed to use negative biomass coefficients")

        # Add biomass to objective
        self._solver.setFlowObjectiveCoeff(self._standardObjectiveReactionName, biomassSatisfactionWeight)

        # Create fraction and biomass outputs
        for moleculeID in objective:
            fractionID = self._generatedID_moleculeEquivalents + moleculeID

            # Biomass out
            self._solver.setFlowMaterialCoeff(self._standardObjectiveReactionName, fractionID, -1)

            # Fraction out
            fractionOutID = self._generatedID_fractionsOut + moleculeID

            self._solver.setFlowMaterialCoeff(fractionOutID, fractionID, -1)

            if moleculeID == leadingMoleculeID:
                # Add leading molecule to objective
                self._solver.setFlowObjectiveCoeff(fractionOutID, +1)

        # Create fraction differences (leading - other), used in objective and constraints
        leadingMoleculeToFractionID = self._generatedID_moleculesToEquivalents + leadingMoleculeID

        for moleculeID in objective:
            if moleculeID == leadingMoleculeID:
                continue

            fractionDifferenceLeadingID = self._generatedID_fractionalDifferenceLeading + moleculeID

            self._solver.setFlowMaterialCoeff(leadingMoleculeToFractionID, fractionDifferenceLeadingID, +1)

            moleculeToFractionID = self._generatedID_moleculesToEquivalents + moleculeID

            self._solver.setFlowMaterialCoeff(moleculeToFractionID, fractionDifferenceLeadingID, -1)

            fractionDifferenceLeadingOutID = self._generatedID_fractionalDifferenceLeadingOut + moleculeID

            self._solver.flowMaterialCoeffIs(fractionDifferenceLeadingOutID, fractionDifferenceLeadingID, -1)

            self._solver.setFlowObjectiveCoeff(fractionDifferenceLeadingOutID, -fractionalDifferenceWeight)

        # Create biomass differences (fraction - biomass), used in constraints

        for moleculeID in objective:
            fractionDifferenceBiomassID = self._generatedID_fractionalDifferenceBiomass + moleculeID

            moleculeToFractionID = self._generatedID_moleculesToEquivalents + moleculeID

            self._solver.setFlowMaterialCoeff(moleculeToFractionID, fractionDifferenceBiomassID, +1)

            self._solver.setFlowMaterialCoeff(fractionDifferenceBiomassID, self._standardObjectiveReactionName, -1)

            fractionDifferenceBiomassOutID = self._generatedID_fractionalDifferenceBiomassOut + moleculeID

            self._solver.setFlowMaterialCoeff(fractionDifferenceBiomassOutID, fractionDifferenceBiomassID, -1)

    def _initObjectiveHomeostatic(self, objective):
        """Create the abstractions needed for homeostatic FBA.  The objective is
        to minimize the distance between the current metabolite level and some
        target level, as defined in the objective."""

        if any(coeff < 0 for coeff in objective.values()):
            raise FBAError("Homeostatic FBA is not designed to use negative biomass coefficients")

        self._homeostaticTargetMolecules.update(set(objective.keys()))
        self._solver.maximizeObjective(False)
        self._forceInternalExchange = True

        # By forcing a column to be at unity, we can keep the definition of
        # the problem as b=Av where b=0.
        self._solver.setFlowBounds(
            self._forcedUnityColName,
            lowerBound=1,
            upperBound=1,
        )

        # Minimizing an absolute value requires splitting the term into two,
        # one for the positive values and one for the negative.
        for moleculeID in sorted(objective):
            objectiveEquivID = self._generatedID_moleculeEquivalents + moleculeID

            # Add the forced -1 term so that we can define x_i = f_i - 1
            self._solver.setFlowMaterialCoeff(
                self._forcedUnityColName,
                objectiveEquivID,
                -1,
            )

            if self._solver.quadratic_objective:
                quadUnityID = self._generatedID_quadFractionFromUnity + moleculeID

                self._solver.setFlowMaterialCoeff(
                    quadUnityID,
                    objectiveEquivID,
                    -1,
                )

                self._solver.setFlowObjectiveCoeff(
                    quadUnityID,
                    self.homeostaticObjectiveWeight,
                )

                self._solver.setFlowBounds(
                    quadUnityID,
                    lowerBound=-self._solver.inf,
                    upperBound=self._solver.inf,
                )
            else:
                # Add the term for when the flux out is below the expected value
                belowUnityID = self._generatedID_fractionBelowUnityOut + moleculeID

                self._solver.setFlowMaterialCoeff(
                    belowUnityID,
                    objectiveEquivID,
                    1,
                )

                self._solver.setFlowObjectiveCoeff(
                    belowUnityID,
                    self.homeostaticObjectiveWeight,
                )

                # Add the term for when the flux out is above the expected value
                aboveUnityID = self._generatedID_fractionAboveUnityOut + moleculeID

                self._solver.setFlowMaterialCoeff(
                    aboveUnityID,
                    objectiveEquivID,
                    -1,
                )

                self._solver.setFlowObjectiveCoeff(
                    aboveUnityID,
                    self.homeostaticObjectiveWeight,
                )

    def _initObjectiveRangeHomeostatic(self, objective, objectiveParameters):
        """Homeostatic FBA with a range of acceptable values. The objective is
        to minimize the distance between the current metabolite level and a range
        of target concentrations. Within this target range, there is a small preference
        for the higher concentration. The low and high ends of the target range are
        defined in the objective."""

        # Load parameters - default to regular homeostatic fba if none given
        homeostaticRangeObjFractionHigher = objectiveParameters.get("homeostaticRangeObjFractionHigher", 0)
        inRangeObjWeight = objectiveParameters.get("inRangeObjWeight", 0)
        if inRangeObjWeight > 1 or inRangeObjWeight < 0:
            raise FBAError(
                "inRangeObjWeight must be between 0 and 1 inclusive."
                + " It represents the weighting relative to the out-of-range relaxation"
                + " fluxes, it must always be less than 1 and positive."
            )

        for moleculeID in sorted(objective):
            objectiveEquivID = self._generatedID_moleculeEquivalents + moleculeID

            # Add the term for when the flux out is within the expected range
            inRangeID = self._generatedID_fractionInRangeOut + moleculeID

            self._solver.setFlowMaterialCoeff(
                inRangeID,
                objectiveEquivID,
                -np.abs(homeostaticRangeObjFractionHigher),
            )

            # Set the weight of running this relaxation
            self._solver.setFlowObjectiveCoeff(
                inRangeID,
                inRangeObjWeight * (self.homeostaticObjectiveWeight),
            )

            # This relaxation can only go to the end of the target range (less and the out range relaxation must be used)
            self._solver.setFlowBounds(
                inRangeID,
                upperBound=abs(homeostaticRangeObjFractionHigher),
            )

    def _initObjectiveKinetic(self, objective, objectiveParameters=None):
        """Given a dict of reaction_name:rate (objective), attempts to
        minimize the normalized distance between fluxes and those rates.
        """

        # Unless given, assume no reactions are one-sided targets (ie kcat only targets)
        self._oneSidedReactions = set(objectiveParameters.get("oneSidedReactionTargets", ""))
        if len(self._oneSidedReactions) and self._solver.quadratic_objective:
            raise Exception("One sided reactions not implemented for quadratic objectives")

        # Track the kinetic target levels
        self._currentKineticTargets = objective

        # This is a minimization objective problem
        self._solver.maximizeObjective(False)

        # Forced a column to always be one
        self._solver.setFlowBounds(
            self._forcedUnityColName,
            lowerBound=1,
            upperBound=1,
        )

        for reactionID, expectedFlux in objective.items():
            if expectedFlux < 0:
                raise FBAError(
                    f"Target flux for reaction {reactionID} is negative. Kinetic targets must be positive - set the value for the (reverse) reaction if a negative flux is desired."
                )

            if reactionID not in self.reactionStoich:
                raise FBAError(
                    f"{reactionID} is not in the reaction network. Target fluxes must be in the reaction network"
                )

            self._kineticTargetFluxes.add(reactionID)
            self._active_kinetic_targets.add(reactionID)

            reactionFluxEquivalent = self._generatedID_reactionFluxEquivalents + reactionID
            # Add a term to the reaction to create a kinetic objective equivalent each time it's run
            self._solver.setFlowMaterialCoeff(
                reactionID,
                reactionFluxEquivalent,
                1.0,
            )

            # Conversion to scale reactions to their target (this is what is changed when changing targets)
            conversionFlux = self._generatedID_conversionFlux + reactionID
            kineticObjEquivalent = self._generatedID_kineticReactionEquivalents + reactionID
            self._solver.setFlowMaterialCoeff(
                conversionFlux,
                reactionFluxEquivalent,
                -expectedFlux,
            )
            self._solver.setFlowMaterialCoeff(
                conversionFlux,
                kineticObjEquivalent,
                1,
            )

            # Force consumption of one kinetic objective equivalent
            self._solver.setFlowMaterialCoeff(
                self._forcedUnityColName,
                kineticObjEquivalent,
                -1,
            )

            ## Create relaxation fluxes to allow deviation from this forced consumption
            if self._solver.quadratic_objective:
                quadTargetFlux = self._generatedID_quadFluxRelax + reactionID
                self._solver.setFlowMaterialCoeff(
                    quadTargetFlux,
                    kineticObjEquivalent,
                    -1,
                )

                self._solver.setFlowBounds(
                    quadTargetFlux,
                    lowerBound=-self._solver.inf,
                    upperBound=self._solver.inf,
                )

                self._specialFluxIDsSet.add(quadTargetFlux)

                # Allow consumption of kinetic objective equivalent at lower objective
                # weight within target range
                range_flux = self._generatedID_target_range + reactionID
                self._solver.setFlowMaterialCoeff(
                    range_flux,
                    kineticObjEquivalent,
                    -1,
                )
                # Set limits to 0, if a target range is set, the upper and/or lower
                # bounds will vary from 0
                self._solver.setFlowBounds(
                    range_flux,
                    lowerBound=0,
                    upperBound=0,
                )
                self._specialFluxIDsSet.add(range_flux)
            else:
                # Add a pseudoreaction to allow the flux to be above its target
                overTargetFlux = self._generatedID_amountOver + reactionID
                self._solver.setFlowMaterialCoeff(
                    overTargetFlux,
                    kineticObjEquivalent,
                    -1,
                )
                self._specialFluxIDsSet.add(overTargetFlux)

                # Add a pseudoreaction to allow the flux to be below its target
                underTargetFlux = self._generatedID_amountUnder + reactionID
                self._solver.setFlowMaterialCoeff(
                    underTargetFlux,
                    kineticObjEquivalent,
                    1,
                )
                self._specialFluxIDsSet.add(underTargetFlux)

                # Allow consumption of kinetic objective equivalent at lower objective
                # weight within target range (above average)
                upper_range_flux = self._generatedID_high_target_range + reactionID
                self._solver.setFlowMaterialCoeff(
                    upper_range_flux,
                    kineticObjEquivalent,
                    -1,
                )
                # Set limits to 0, if a target range is set, the upper bound will vary from 0
                self._solver.setFlowBounds(
                    upper_range_flux,
                    lowerBound=0,
                    upperBound=0,
                )
                self._specialFluxIDsSet.add(upper_range_flux)

                # Allow consumption of kinetic objective equivalent at lower objective
                # weight within target range (below average)
                lower_range_flux = self._generatedID_low_target_range + reactionID
                self._solver.setFlowMaterialCoeff(
                    lower_range_flux,
                    kineticObjEquivalent,
                    1,
                )
                # Set limits to 0, if a target range is set, the upper bound will vary from 0
                self._solver.setFlowBounds(
                    lower_range_flux,
                    lowerBound=0,
                    upperBound=0,
                )
                self._specialFluxIDsSet.add(lower_range_flux)

        self.set_scaled_kinetic_objective()

    def set_scaled_kinetic_objective(self, scaling=None, reactions=None):
        """
        Set objective coefficients for kinetic constraints.

        Args:
                scaling (float): scaling for time step length to ensure time step
                        independence in FBA problem, must be 0 or None if reactions
                        are also supplied
                reactions (Set[str]): reactions with kinetic targets to set the
                        objective coefficient, if None, uses only active kinetic
                        constraint reactions
        """

        # Check valid inputs for scaling
        if scaling is None:
            # Use stored value is nothing is provided
            scaling = self._kinetic_objective_scaling
        elif reactions is None:
            # # No need to update if active targets have already been set
            # if scaling == self._kinetic_objective_scaling:
            # 	return

            # Only update stored value if setting for all active targets
            self._kinetic_objective_scaling = scaling
        elif scaling != 0 and scaling != self._kinetic_objective_scaling:
            raise ValueError("Not able to set a different non-zero scaling factor for only a subset of reactions")

        # Use default reactions if not given
        if reactions is None:
            reactions = self._active_kinetic_targets

        # Update objective coefficient for each reaction
        for rxn in reactions:
            if self._solver.quadratic_objective:
                quadTargetFlux = self._generatedID_quadFluxRelax + rxn
                self._solver.setFlowObjectiveCoeff(
                    quadTargetFlux,
                    self.kineticObjectiveWeight * scaling,
                )

                quad_range_flux = self._generatedID_target_range + rxn
                self._solver.setFlowObjectiveCoeff(
                    quad_range_flux,
                    self.kinetic_objective_weight_in_range * scaling,
                )
            else:
                # Objective is to minimize running this relaxation reaction
                overTargetFlux = self._generatedID_amountOver + rxn
                self._solver.setFlowObjectiveCoeff(
                    overTargetFlux,
                    self.kineticObjectiveWeight * scaling,
                )

                # Objective is to minimize running this relaxation reaction
                # unless this is a one-sided kinetic target, in which case
                # it's a free relaxation.
                underTargetFlux = self._generatedID_amountUnder + rxn
                if rxn not in self._oneSidedReactions:
                    self._solver.setFlowObjectiveCoeff(
                        underTargetFlux,
                        self.kineticObjectiveWeight * scaling,
                    )

                # Objective is to minimize running this relaxation reaction
                upper_range_flux = self._generatedID_high_target_range + rxn
                self._solver.setFlowObjectiveCoeff(
                    upper_range_flux,
                    self.kinetic_objective_weight_in_range * scaling,
                )

                # Objective is to minimize running this relaxation reaction
                lower_range_flux = self._generatedID_low_target_range + rxn
                if rxn not in self._oneSidedReactions:
                    self._solver.setFlowObjectiveCoeff(
                        lower_range_flux,
                        self.kinetic_objective_weight_in_range * scaling,
                    )

    def _initInternalExchange(self, internalExchangedMolecules):
        """Create internal (byproduct) exchange reactions."""

        internalMoleculeIDs = []

        if internalExchangedMolecules is not None:
            for moleculeID in internalExchangedMolecules:
                exchangeID = self._generatedID_internalExchange + moleculeID

                if moleculeID in internalMoleculeIDs:
                    print("Warning: attempting to set multiple internal exchanges" + f" for {moleculeID}. Skipping...")
                    continue

                self._solver.setFlowMaterialCoeff(exchangeID, moleculeID, -1)

                internalMoleculeIDs.append(moleculeID)
                self._specialFluxIDsSet.add(exchangeID)

                if moleculeID not in self._outputMoleculeIDs:
                    self._outputMoleculeIDs[moleculeID] = len(self._outputMoleculeIDs)
                    self._outputMoleculeCoeffs.append(dict())
                i = self._outputMoleculeIDs[moleculeID]
                self._outputMoleculeCoeffs[i][exchangeID] = -1

        self._internalMoleculeIDs = tuple(internalMoleculeIDs)

    def _initExchangeMass(self, externalExchangedMolecules, moleculeMasses, secretionPenaltyCoeff):
        """Create mass accumulation abstractions.
        Tracking the mass entering the system through metabolism is crucial for
        insuring closure. It can also be used to constrain the maximal rate of
        growth."""

        if moleculeMasses is not None:
            self._solver.setFlowMaterialCoeff(self._massExchangeOutName, self._massExchangeID, -1)

            for moleculeID in externalExchangedMolecules:
                exchangeFluxID = self._generatedID_externalExchange + moleculeID

                try:
                    moleculeMass = moleculeMasses[moleculeID]

                except KeyError:
                    raise FBAError("You must provide masses for all molecules in externalExchangedMolecules")

                self._solver.setFlowMaterialCoeff(
                    exchangeFluxID,
                    self._massExchangeID,
                    -moleculeMass,  # NOTE: negative because exchange fluxes point out
                )

                if secretionPenaltyCoeff is not None:
                    # Limit secretion/export
                    absID = self._generatedID_externalExchangeAbs + moleculeID
                    posExchangeID = self._generatedID_externalExchangePos + moleculeID
                    negExchangeID = self._generatedID_externalExchangeNeg + moleculeID

                    self._solver.setFlowMaterialCoeff(exchangeFluxID, absID, moleculeMass)

                    self._solver.setFlowMaterialCoeff(posExchangeID, absID, -1)

                    self._solver.setFlowMaterialCoeff(negExchangeID, absID, +1)

                    self._solver.setFlowObjectiveCoeff(posExchangeID, secretionPenaltyCoeff)

    def _initMaintenance(self, maintenanceCostGAM, maintenanceReaction):
        """Create growth-associated maintenance abstractions.
        Two maintenance costs are typically associated with FBA; growth-
        associated maintenance is the energetic cost of increasing cell mass by
        a certain amount.  (Contrast non-growth-associated maintenance, which
        is a fixed energetic cost regardless of mass accumulation.)
        """

        if (maintenanceCostGAM is None) and (maintenanceReaction is None):
            return

        if (maintenanceCostGAM is None) ^ (maintenanceReaction is None):
            raise FBAError("Must pass all or none of maintenanceCostGAM, maintenanceReaction")

        # TODO: check that the mass flux stuff exists

        # computed mass output produces "GAM reactions"...

        self._solver.setFlowMaterialCoeff(self._massExchangeOutName, self._pseudometaboliteGAM, maintenanceCostGAM)

        # ... which are consumed in a separate flux

        self._solver.setFlowMaterialCoeff(self._reactionID_GAM, self._pseudometaboliteGAM, -1)

        for moleculeID, stoichCoeff in maintenanceReaction.items():
            self._solver.setFlowMaterialCoeff(self._reactionID_GAM, moleculeID, stoichCoeff)

            self._solver.setFlowMaterialCoeff(self._reactionID_NGAM, moleculeID, stoichCoeff)

            self._solver.setFlowMaterialCoeff(self._reactionID_polypeptideElongationEnergy, moleculeID, stoichCoeff)

        self._specialFluxIDsSet.add(self._reactionID_GAM)
        self._specialFluxIDsSet.add(self._reactionID_NGAM)
        self._specialFluxIDsSet.add(self._reactionID_polypeptideElongationEnergy)

    def _buildEqConst(self):
        self._solver.buildEqConst()

    # Constraint setup

    # NOTE: I've used the nonspecific term "levels" since these could be
    # mole-concentration, number-concentration, or counts, depending on the
    # formulation of the problem.  All that matters is that initialization
    # parameters have consistent units.

    def getExternalMoleculeIDs(self):
        return self._externalMoleculeIDs

    def setExternalMoleculeLevels(self, levels, molecules=None, force=False, allow_export=False):
        """
        Sets levels of external molecules that the cell can access. A positive
        level indicates molcules that are available for uptake while a negative
        level indicates secretion.

        Args:
                levels: limits corresponding to each molecule or to all molecules if
                        a singleton is passed
                molecules: the molecule IDs to assign limits to
                force: if True, will force exchange by setting both the upper and
                        lower bounds to the levels provided
                allow_export: TODO
        """

        if molecules is None:
            molecules = self._externalMoleculeIDs
        elif isinstance(molecules, str):
            molecules = [molecules]

        levels_array = np.empty(len(molecules))
        levels_array[:] = levels

        for moleculeID, level in zip(molecules, levels_array):
            flowID = self._generatedID_externalExchange + moleculeID

            lb = None
            ub = None
            if level < 0:
                if not allow_export:
                    print(
                        f"Setting a negative external molecule level for {moleculeID} - be sure this is intended behavior."
                    )

                ub = -level
                if force:
                    lb = ub
            else:
                lb = -level
                if force:
                    ub = lb

            self._solver.setFlowBounds(
                flowID,
                lowerBound=lb,
                upperBound=ub,
            )

    def getInternalMoleculeIDs(self):
        return self._internalMoleculeIDs

    def setInternalMoleculeLevels(self, levels):
        levels_array = np.empty(len(self._internalMoleculeIDs))
        levels_array[:] = levels

        if (levels_array < 0).any():
            raise InvalidBoundaryError("Negative molecule levels not allowed")

        for moleculeID, level in zip(self._internalMoleculeIDs, levels_array):
            flowID = self._generatedID_internalExchange + moleculeID

            if self._forceInternalExchange:
                self._solver.setFlowBounds(
                    flowID,
                    lowerBound=-level,
                    upperBound=-level,
                )
            else:
                self._solver.setFlowBounds(
                    flowID,
                    lowerBound=-level,
                )

    def getReactionIDs(self):
        return np.array(self._reactionIDs)

    def setReactionFluxBounds(self, reactionIDs, lowerBounds=None, upperBounds=None, raiseForReversible=True):
        """
        Sets the upper and lower bounds for a group of reactions.
        If upper or lower bounds are not specified (None), they are left as is.
        Passing a string as the first argument will automatically convert
        arguments to a list and lowerBounds and upperBounds should be floats.
        inputs:
                reactionIDs (str or array-like) - list of reactions to set flux bounds
                lowerBounds (float or array-like) - lower bound of flux for each reactionID
                upperBounds (float or array-like) - upper bound of flux for each reactionID
                raiseForReversible (bool) - if true, raises error if there is a
                        reverse reaction since net flux bounds might not be set
        """

        if isinstance(reactionIDs, str):
            reactionIDs = [reactionIDs]
            if lowerBounds is not None:
                lowerBounds = [lowerBounds]
            if upperBounds is not None:
                upperBounds = [upperBounds]

        nReactions = len(reactionIDs)
        if lowerBounds is None:
            lowerBounds = [None] * nReactions
        elif np.any(np.array(lowerBounds) < 0):
            raise InvalidBoundaryError("Minimum reaction flux must be non-negative")
        if upperBounds is None:
            upperBounds = [None] * nReactions
        elif np.any(np.array(upperBounds) < 0):
            raise InvalidBoundaryError("Maximum reaction flux must be non-negative")

        if nReactions != len(lowerBounds) or nReactions != len(upperBounds):
            raise Exception("There must be equal numbers of reactionIDs and bounds to set limits.")

        if raiseForReversible:
            for reactionID in reactionIDs:
                reverseReactionID = self._generatedID_reverseReaction + reactionID
                if reverseReactionID in self._reactionIDsSet:
                    raise FBAError(
                        (
                            "Setting the reaction flux is ambiguous since "
                            + "reaction {} has both a forward [{}] and reverse [{}] "
                            + "component.  Call this method with argument "
                            + "raiseForReversible = False if this is intended behavior."
                        ).format(reactionID, reactionID, reverseReactionID)
                    )

        # Set reaction flux bounds for each reaction
        for reactionID, lb, ub in zip(reactionIDs, lowerBounds, upperBounds):
            if reactionID not in self._reactionIDsSet and reactionID not in self._specialFluxIDsSet:
                raise InvalidBoundaryError("Unable to set reaction flux: reaction '%s' not recognized." % (reactionID,))

            self._solver.setFlowBounds(
                reactionID,
                upperBound=ub,
                lowerBound=lb,
            )

    def update_homeostatic_targets(self, objective):
        """
        Sets the homeostatic objective target values.

        Args:
                objective (dict): targets for each molecule
                        {molecule (str): target (float)}
        """

        for molecule_id, coeff in objective.items():
            if coeff < 0:
                raise ValueError(f"Homeostatic target must be non-negative. It is {coeff} for {molecule_id}.")

            if molecule_id not in self._outputMoleculeIDs:
                raise FBAError(
                    "This function only allows for modification of setpoint values, "
                    + "not adding new ones. %s is an unrecognized molecule" % molecule_id
                )

            pseudo_flux_id = self._generatedID_moleculesToEquivalents + molecule_id

            self._solver.setFlowMaterialCoeff(pseudo_flux_id, molecule_id, -coeff)

            i = self._outputMoleculeIDs[molecule_id]
            self._outputMoleculeCoeffs[i][pseudo_flux_id] = -coeff

    def setMaxMassAccumulated(self, maxAccumulation):
        self._solver.setFlowBounds(
            self._massExchangeOutName,
            upperBound=maxAccumulation,
        )

    # Output

    def getOutputMoleculeIDs(self):
        return tuple(k for k, v in sorted(self._outputMoleculeIDs.items(), key=lambda d: d[1]))

    def getOutputMoleculeLevelsChange(self):
        change = np.zeros(len(self._outputMoleculeIDs))

        for i, stoich in enumerate(self._outputMoleculeCoeffs):
            flowRates = self._solver.getFlowRates(stoich.keys())
            coeffs = list(stoich.values())
            change[i] = np.dot(flowRates, coeffs)

        return -change

    def getExternalExchangeFluxes(self):
        return self._solver.getFlowRates(self._externalExchangeIDs)

    def getReactionFlux(self, reactionID):
        return self._solver.getFlowRates(reactionID)

    def getReactionFluxes(self, reactionIDs=None):
        if reactionIDs is None:
            reactionIDs = self._reactionIDs
        return self._solver.getFlowRates(reactionIDs)

    def getShadowPrices(self, moleculeIDs):
        return self._solver.getShadowPrices(moleculeIDs)

    def getReducedCosts(self, moleculeIDs):
        return self._solver.getReducedCosts(moleculeIDs)

    def getBiomassReactionFlux(self):
        if self.objectiveType not in ("standard", "flexible", "moma"):
            raise FBAError(f"There is no biomass reaction for this objective type ({self.objectiveType})")
        return self._solver.getFlowRates(self._standardObjectiveReactionName)[0]

    def getHomeostaticObjectiveValues(self, moleculeIDs=None):
        if moleculeIDs is None:
            moleculeIDs = self.getHomeostaticTargetMolecules()
        values = np.zeros(len(moleculeIDs))
        for idx, moleculeID in enumerate(moleculeIDs):
            if moleculeID not in self._homeostaticTargetMolecules:
                raise FBAError(f"No homeostatic target set for molecule {moleculeID}.")

            if self._solver.quadratic_objective:
                quadUnityID = self._generatedID_quadFractionFromUnity + moleculeID
                relax = self.getReactionFlux(quadUnityID) ** 2
            else:
                belowUnityID = self._generatedID_fractionBelowUnityOut + moleculeID
                aboveUnityID = self._generatedID_fractionAboveUnityOut + moleculeID
                relaxUp = self.getReactionFlux(belowUnityID)
                relaxDown = self.getReactionFlux(aboveUnityID)

                relax = relaxUp + relaxDown

                assert relaxUp <= NUMERICAL_ZERO or relaxDown <= NUMERICAL_ZERO

            values[idx] = relax[0]
        return values

    def getHomeostaticObjectiveWeight(self):
        return self.homeostaticObjectiveWeight

    def getHomeostaticTargetMolecules(self):
        return sorted(self._homeostaticTargetMolecules)

    def getObjectiveValue(self):
        return self._solver.getObjectiveValue()

    def getKineticReactionFluxTargets(self, reactionIDs=None):
        # TODO (Travis): get upper and lower targets as well
        if reactionIDs is None:
            reactionIDs = self.getKineticTargetFluxNames()
        values = np.zeros(len(reactionIDs))
        for idx, reactionID in enumerate(reactionIDs):
            if reactionID not in self._currentKineticTargets:
                raise FBAError(f"No kinetic target set for reaction {reactionID}.")
            values[idx] = self._currentKineticTargets[reactionID]
        return values

    def getKineticTargetFluxErrors(self, reactionIDs=None):
        if reactionIDs is None:
            reactionIDs = self.getKineticTargetFluxNames()
        errors = self.getReactionFluxes(reactionIDs) - self.getKineticReactionFluxTargets(reactionIDs)
        # Adjust for any one-sided reactions
        if len(self._oneSidedReactions) > 0:
            oneSidedNegativeErrors = np.where([
                True if reactionID in self._oneSidedReactions and errors[idx] < 0 else False
                for idx, reactionID in enumerate(reactionIDs)
            ])
            errors[oneSidedNegativeErrors] = 0
        return errors

    def getKineticTargetFluxRelativeDifferences(self, reactionIDs=None):
        if reactionIDs is None:
            reactionIDs = self.getKineticTargetFluxNames()
        return self.getKineticTargetFluxErrors(reactionIDs) / self.getKineticReactionFluxTargets(reactionIDs)

    def getKineticTargetFluxNames(self):
        return sorted(self._kineticTargetFluxes)

    def getKineticOneSidedTargetFluxNames(self):
        return sorted(self._oneSidedReactions)

    def getKineticObjectiveValues(self, reactionIDs=None):
        """
        Returns the value of the kinetic objective associated with each reaction that
        is part of the kinetic objective.  If the reaction is disabled (not part of
        the objective) then the value will be 0.

        Inputs:
                reactionIDs (iterable of str) - specific reaction IDs to get objective
                        value for (if not provided then will return for all reactions in
                        the objective)

        TODO:
                Should the range fluxes be weighted?
        """

        if reactionIDs is None:
            reactionIDs = self.getKineticTargetFluxNames()

        if not reactionIDs:
            return np.array([])

        values = np.zeros(len(reactionIDs))

        # Get all reaction fluxes at once for faster performance
        if self._solver.quadratic_objective:
            id_templates = [
                self._generatedID_quadFluxRelax,
                self._generatedID_target_range,
            ]
        else:
            id_templates = [
                self._generatedID_amountUnder,
                self._generatedID_amountOver,
                self._generatedID_low_target_range,
                self._generatedID_high_target_range,
            ]
        generated_ids = [i + rxn for rxn in reactionIDs for i in id_templates]
        fluxes = {rxn: flux for rxn, flux in zip(generated_ids, self.getReactionFluxes(generated_ids))}

        for idx, reactionID in enumerate(reactionIDs):
            if reactionID not in self._active_kinetic_targets:
                continue
            if reactionID not in self._kineticTargetFluxes:
                raise FBAError(f"No kinetic target set for reaction {reactionID}.")

            if self._solver.quadratic_objective:
                quadUnityID = self._generatedID_quadFluxRelax + reactionID
                quad_range_id = self._generatedID_target_range + reactionID
                relax = fluxes[quadUnityID] ** 2 + self.kinetic_objective_weight_in_range * fluxes[quad_range_id] ** 2
            else:
                belowUnityID = self._generatedID_amountUnder + reactionID
                aboveUnityID = self._generatedID_amountOver + reactionID
                below_in_range_id = self._generatedID_low_target_range + reactionID
                above_in_range_id = self._generatedID_high_target_range + reactionID
                relaxUp = fluxes[belowUnityID]
                relaxDown = fluxes[aboveUnityID]
                below_in_range = fluxes[below_in_range_id]
                above_in_range = fluxes[above_in_range_id]

                relax = relaxUp + relaxDown + self.kinetic_objective_weight_in_range * (below_in_range + above_in_range)

                assert relaxUp <= NUMERICAL_ZERO or relaxDown <= NUMERICAL_ZERO

            values[idx] = relax
        return values

    def setKineticTarget(self, reactionIDs, mean_targets, lower_targets=None, upper_targets=None):
        # If a single value is passed in, make a list of length 1 from it
        def validate_targets(targets, reactionIDs, default=None):
            if targets is None:
                return default

            if not (isinstance(targets, (list, np.ndarray))):
                targets = [targets]

            if (np.array(targets) < 0).any():
                raise FBAError(
                    f"Rate targets cannot be negative. {np.array(reactionIDs)[np.array(targets) < 0]} were provided with targets of {np.array(targets)[np.array(targets) < 0]}"
                )

            if len(targets) != len(reactionIDs):
                raise FBAError(
                    "There must be equal numbers of reactionIDs and targets when changing the kinetic target."
                )

            return targets

        if isinstance(reactionIDs, str):
            reactionIDs = [reactionIDs]
        mean_targets = validate_targets(mean_targets, reactionIDs)
        lower_targets = validate_targets(lower_targets, reactionIDs, default=mean_targets)
        upper_targets = validate_targets(upper_targets, reactionIDs, default=mean_targets)

        # Change the objective normalization
        for reactionID, mean, lower, upper in zip(reactionIDs, mean_targets, lower_targets, upper_targets):
            if reactionID not in self._kineticTargetFluxes:
                raise FBAError(
                    "Kinetic targets can only be set for reactions"
                    f" initialized to be kinetic targets. {reactionID} is not set up for it."
                )

            if (upper < mean and not np.isclose(upper, mean)) or (mean < lower and not np.isclose(mean, lower)):
                raise FBAError(
                    f"Incorrect ordering of kinetic targets: {lower} {mean} {upper}. Must have lower <= mean <= upper"
                )

            conversionFlux = self._generatedID_conversionFlux + reactionID
            reactionFluxEquivalent = self._generatedID_reactionFluxEquivalents + reactionID
            if mean == 0:
                # can't have coeff = 0, target is disabled so -1 is arbitrary value that won't matter
                self._solver.setFlowMaterialCoeff(conversionFlux, reactionFluxEquivalent, -1)
                self.disableKineticTargets(reactionID)
            else:
                if self._currentKineticTargets[reactionID] == 0:
                    self.enableKineticTargets(reactionID)

                self._solver.setFlowMaterialCoeff(conversionFlux, reactionFluxEquivalent, -mean)

                if self._solver.quadratic_objective:
                    range_flux = self._generatedID_target_range + reactionID
                    self._solver.setFlowBounds(
                        range_flux,
                        lowerBound=-(1 - lower / mean),
                        upperBound=(upper / mean - 1),
                    )
                else:
                    lower_range_flux = self._generatedID_low_target_range + reactionID
                    self._solver.setFlowBounds(
                        lower_range_flux,
                        upperBound=np.fmax(0, 1 - lower / mean),
                    )
                    upper_range_flux = self._generatedID_high_target_range + reactionID
                    self._solver.setFlowBounds(
                        upper_range_flux,
                        upperBound=np.fmax(0, upper / mean - 1),
                    )

            # Record the change
            self._currentKineticTargets[reactionID] = mean

    def enableKineticTargets(self, reactionIDs=None):
        # If a single value is passed in, make a list of length 1 from it
        if isinstance(reactionIDs, str):
            reactionIDs = [reactionIDs]

        # If no reactions specified, enable all kinetic reactions
        if reactionIDs is None:
            print("enabled kinetic rates")
            reactionIDs = self.getKineticTargetFluxNames()

        self._active_kinetic_targets.update(reactionIDs)
        self.set_scaled_kinetic_objective(reactions=reactionIDs)

    def disableKineticTargets(self, reactionIDs=None):
        # If a single value is passed in, make a list of length 1 from it
        if isinstance(reactionIDs, str):
            reactionIDs = [reactionIDs]

        # If no reactions specified, disable all kinetic reactions
        if reactionIDs is None:
            print("disabled kinetic rates")
            reactionIDs = self.getKineticTargetFluxNames()

        for reactionID in reactionIDs:
            if reactionID not in self._active_kinetic_targets:
                continue

            self._active_kinetic_targets.remove(reactionID)

            # Reset flux target - leaving low values can cause issues when objective for reaction is disabled
            conversionFlux = self._generatedID_conversionFlux + reactionID
            reactionFluxEquivalent = self._generatedID_reactionFluxEquivalents + reactionID
            self._solver.setFlowMaterialCoeff(
                conversionFlux,
                reactionFluxEquivalent,
                -1,
            )

        self.set_scaled_kinetic_objective(scaling=0, reactions=reactionIDs)

    def getArrayBasedModel(self):
        return {
            "S_matrix": self._solver.getSMatrix(),
            "Reactions": self._solver.getFlowNames(),
            "Metabolites": self._solver.getMaterialNames(),
            "Upper bounds": self._solver.getUpperBounds(),
            "Lower bounds": self._solver.getLowerBounds(),
            "Objective": self._solver.getObjective(),
        }

    def getMassAccumulated(self):
        return self._solver.getFlowRates(self._massExchangeOutName)

    def solve(self, iterations):
        if iterations == 0:
            self._solver._solve()
        else:
            try:
                self._solver._solve()
                return
            except Exception as inst:
                print(f"Warning: {inst} error while solving FBA - repeating FBA solve")
            self.solve(iterations - 1)
