Skip to content

Lactic Acid Fermentation Endothermic but should be Exothermic. Bug or limit of biosteam? #231

@iilard

Description

@iilard

Hi, I will post this again because the other entry remained inactive sofar. I know you only have limited time for BioSTEAM and this github, but I would really appreciate your support on this issue, as it's the main unit for the process and of integral importance for my thesis, in which I evaluate BioSTEAM as a tool for LCI creation.

My fermentor is endothermic, and I have tried various fixes but nothing works. The only "success" I had, was arbitrarily adjusting the Hf value of Calcium Lactate, from literature backed value -1686100 J/mol to ex. -1930000 J/mol. This "knob" makes the fermentor unit exothermic. I was not able to spot any other setting to make it work.

**

  • Can this be fixed or is it a bug?
  • Is the issue that salts can't be properly simulated in Thermosteam? (dissociated species and such)
  • Is there any way to "workaround"?**

Below my code (separate py files):

_settings.py:

import thermosteam as tmo
import biosteam as bst
from _chemicals import chemicals 
from thermosteam import settings

#Activate chemicals list & register them in bio/thermosteam
tmo.settings.set_thermo(chemicals) 
bst.settings.set_thermo(chemicals)

_chemicals.py:

import thermosteam as tmo
import biosteam as bst
from thermosteam import Chemicals, Chemical

def create_chemicals():
    chemicals = Chemicals([])
    def add_chemical(ID, ref=None, **data):
        chemical = Chemical(ID, **data) if ref is None else ref.copy(ID, **data)
        chemicals.append(chemical)
        return chemical
    
    Water = add_chemical('H2O')
    Glucose = add_chemical('Glucose', phase='l', rho=1560, Cp=1.213)
    CaCO3 = add_chemical('CaCO3', phase='s', rho=2710, Cp=0.834, default=True)
    CalciumLactate = add_chemical('CalciumLactate', phase='l', Hf=-1930000, rho=1494) 
    lla = add_chemical('L-LacticAcid', phase='l', Hf=-686300, Cp=2.109, rho=1206)
    SulfuricAcid = add_chemical('H2SO4', phase='l', rho=1840, Cp=1.38)
    CarbonDioxide = add_chemical('CO2', phase='g')
    Gypsum = add_chemical('CaSO4_2H2O', phase='s', rho=2320, MW=172.18, Hf=-2023000, Cp=1.081, CAS='10101-41-4', mu = 0.00095, search_db=False)
    Biomass_l = add_chemical('Biomass_l', phase='l', formula='CH1.8O0.5N0.2', MW=24.626, rho=1050, Hf=-106800, Cp=1.25, search_db=False)
    Biomass_s = add_chemical('Biomass_s', phase='s', formula='CH1.8O0.5N0.2', MW=24.626, rho=1093, Hf=-130412, Cp=1.25, search_db=False) # Hf value taken from bioindustrial park, no literature given
    nutrient_N = add_chemical('NH3', phase='l')
    Octanol = add_chemical('Octanol', phase='l')
    Trioctylamine = add_chemical('Trioctylamine', phase='l')
    SodiumHydroxide = add_chemical('NaOH', phase='l') 
    SodiumSulfate = add_chemical('Na2SO4', phase='l', rho=1200, Cp=3.5, mu = 0.001, search_db=True)

    chemicals.compile()
    return chemicals

chemicals = create_chemicals()

# show chemical properties
for chemical in chemicals:
    chemical.show()

_reactions.py:

import thermosteam as tmo

# Fermentor Unit Parallel Reactions
def fermentation_reaction():
    fermentation = tmo.Reaction(reaction='Glucose -> 1.84 L-LacticAcid + 0.512 Biomass_s', reactant='Glucose', X=1, basis='mol', correct_atomic_balance=False) 
    return fermentation
# NOTE: Homo-lactic fermentation should theoretically yield 2 moles of lactic acid per mole of consumed glucose 
# with a theoretical yield of 1 g of product per g of substrate, but the experimental yields are usually  
# lower (0.74–0.99 g/g) because a portion of the carbon source is used for biomass production (0.07–0.22 g/g) [AIMS Microbiology, 4(4): 665–684.]
# --> This was used to calculate adjusted coefficients that already implement the empirical yields!


# Separate Neutralization for correct mass balance (has to happen when lla alreaedy formed)
def neutralization_reaction():
    neutralization = tmo.Reaction(reaction='2 L-LacticAcid + CaCO3 -> CalciumLactate + CO2 + H2O', reactant='L-LacticAcid', X=1, basis='mol')
    return neutralization

# Acidification Tank Unit
def acidification_reaction():
    acidification = tmo.Reaction(reaction='CalciumLactate + H2SO4 -> 2 L-LacticAcid + CaSO4_2H2O', reactant='CalciumLactate', X=1, basis='mol')
    return acidification

# Caustic Wash
def caustic_wash_reaction():
    caustic_wash = tmo.Reaction(reaction='2 NaOH + H2SO4 -> Na2SO4 + 2 H2O', reactant='H2SO4', X=1, basis='mol')
    return caustic_wash

_units.py:

import biosteam as bst
import thermosteam as tmo
from biosteam import CSTR
import numpy as np
from _reactions import fermentation_reaction, neutralization_reaction, acidification_reaction

# -----------------------------------CUSTOM CLASSES----------------------------------------

# Custom Fermentor based on CSTR for cont. ferm.
class Fermentor(bst.CSTR):
    _N_ins = 1
    _N_outs = 2
    def __init__(self, ID='Fermentor', ins=None, outs=(), thermo=None, *, T=323.15, **kwargs):
        if outs == ():
            outs = [None, None]  # liquid, gas
        super().__init__(ID, ins, outs, thermo=thermo, **kwargs)
        self.T=T
        self.fermentation_rxn = fermentation_reaction()
        self.neutralization_rxn = neutralization_reaction()

    def _run(self):
        feed = self.ins[0]
        liquid_effluent, gas_vent = self.outs
        liquid_effluent.copy_like(feed)
        # Apply reactions using the class-defined reactions, first parallel fermentation, then neutralization
        self.fermentation_rxn(liquid_effluent.mol)
        self.neutralization_rxn(liquid_effluent.mol)
        # Handle gas separation - remove CO2 from liquid and send to gas vent
        gas_vent.copy_flow(liquid_effluent, 'CO2', remove=True)
        liquid_effluent.phase = 'l'
        gas_vent.phase = 'g'
        liquid_effluent.T = gas_vent.T = self.T


# Custom Acidification Reactor
class AcidificationReactor(bst.CSTR):
    _N_ins = 2
    _N_outs = 1

    def __init__(self, ID='', ins=None, outs=(), P=101325, T=298.15, tau=1.0, V=1.0):
        bst.CSTR.__init__(self, ID, ins, outs, tau=tau, T=T, P=P)
        self.acidification_rxn = acidification_reaction()
        
    def _run(self):
        feed, acid = self.ins
        effluent = self.outs[0]

        # Acid dosing
        n_CL = feed.imol['CalciumLactate']
        acid.imol['H2SO4'] = n_CL * 1.1
        acid.imass['H2O'] = acid.imass['H2SO4'] / 0.93 * 0.07

        # Mix and pre‑instantiate phases
        effluent.mix_from([feed, acid])
        effluent.phases = ('l', 's')
        effluent.T = self.T
        effluent.P = self.P

        self.acidification_rxn(effluent)

        # Force gypsum into solid phase (safety net)
        n_total = effluent.imol['CaSO4_2H2O']
        if n_total:
            effluent.imol['l', 'CaSO4_2H2O'] = 0
            effluent.imol['s', 'CaSO4_2H2O'] = n_total

        # Keep outlet as slurry/liquid
        effluent.phase = 'l'

_system.py:

import biosteam as bst
from _units import Fermentor, AcidificationReactor  # custom classes
from _reactions import fermentation_reaction, acidification_reaction, caustic_wash_reaction  # reaction functions
from _settings import chemicals  # compiled thermo package

# -------------------- FEED STREAMS FOR ALL UNITS --------------------
glucose_feed = bst.Stream('glucose_feed', Glucose=80, Water=920, units='kg/hr') # 80 g/L
CaCO3_feed = bst.Stream('CaCO3_feed', CaCO3=44.5, Water=82.6, units='kg/hr') # Based on mol calc. from reaction; 1mol CaCO3 per 1mol Glucose
Nutrient_feed = bst.Stream('Nutrient_feed', NH3 = 1.34, H2O = 3.13, units='kg/hr')
H2SO4_feed = bst.Stream('H2SO4_feed', units='kg/hr')
Organic_Solvent = bst.Stream('Organic_Solvent', Octanol=172.5, Trioctylamine=172.5, units='kg/hr') # 1.5 : 1 ratio -> org : aq phase
Extr_Water_1 = bst.Stream('Extraction_water_1', H2O=100, units='kg/hr') # NOTE How much?
Extr_Water_2 = bst.Stream('Extraction_water_2', H2O=100, units='kg/hr') # NOTE How much?
Caustic_Sol = bst.Stream('Caustic_Solution', NaOH=4, H2O=50, units='kg/hr')
OrgSolvent_Makeup = bst.Stream('Solvent_Makeup', Octanol=1.72, Trioctylamine=1.73, units='kg/hr') # Add the 1% of solvent lost in caustic wash
Recycled_Solvent = bst.Stream('Recycled_Solvent')

# -------------------- SUBSYSTEM 1: CONVERSION --------------------
# Unit and stream definitions
with bst.System('conversion_sys') as conversion_sys:
    feedstock_mixer = bst.Mixer('feedstock_mixer', ins=[glucose_feed, CaCO3_feed, Nutrient_feed], outs=('crude_feed'))
    feed_pump = bst.Pump('feed_pump', feedstock_mixer.outs[0], outs=('crude_feed'), P=110000)  # NOTE: Provisional Pump; missing correct settings
    preheater = bst.HXutility('preheater', feed_pump.outs[0], outs=('heated_feed'), T=323.15) # NOTE: Placeholder 50°C
    fermentor = Fermentor('fermentor', preheater.outs[0], outs=['fermented_broth', 'CO2'], P=110000, T=323.15, tau=72, V_wf=0.8, kW_per_m3=0.05, dT_hx_loop=35)    # NOTE find source for possible kW_per_m3 value for anaerobic ferm.
    ferm_cooler = bst.HXutility('fermentor_cooler', fermentor.outs[0], outs=('cooled_broth'), T=298.15)


# -------------------- SUBSYSTEM 2.1: SEPARATION --------------------
# Biomass Removal --> NOTE: Fine tuning of splits, realism vs. idealism
# Acidification & Gypsum Filtration

with bst.System('separation_sys') as separation_sys:
    gravity_decanter = bst.units.SolidsSeparator('GravityDecanter',
                                            ins=ferm_cooler.outs[0], outs=('decanted_cake', 'decanted_broth'),
                                            split={'Biomass_s': 1, 'CaCO3': 0.85, 'CalciumLactate': 0.05}, # NOTE: FINE TUNING, HOW MUCH PRODUCT LOST?
                                            moisture_content=0.35)
    bm_Filter = bst.units.SolidsSeparator('BM_Filter',
                                          ins=gravity_decanter.outs[0], outs=('BM_cake', 'BM_filtrate'),
                                          split={'Biomass_s': 1, 'CaCO3': 0.75},
                                          moisture_content=0.08)
    filtrate_mixer = bst.Mixer('filtrate_mixer', ins=(gravity_decanter.outs[1], bm_Filter.outs[1]), outs='clarified_broth')
    acidification_reactor = AcidificationReactor('AcidificationReactor', ins=[filtrate_mixer.outs[0], 'H2SO4_feed'], outs=('acidified_slurry'))
    gypsum_filter = bst.units.SolidsSeparator('GypsumFilter', ins=acidification_reactor.outs[0], outs=('gypsum_cake', 'lactic_acid_solution'), 
                                          split={'CaSO4_2H2O': 0.99}, # Fine particles stay, removed by extraction
                                          moisture_content=0.2)


# -------------------- SUBSYSTEM 2.2A: PURIFICATION --------------------
# MEE 1 to conc. extraction feed
# Extraction with organic amine solvent
# 2 Stage Back-Extraction into fresh water
# MEE 2 to 88wt% lla

with bst.System('purification_sys') as purification_sys:
    Evaporator_1 = bst.units.MultiEffectEvaporator(ID='MEE_pre_extr', ins=[gypsum_filter.outs[1]], outs=('conc_la_sol', 'condensate'), 
                                                   V=0.85, V_definition='Overall', # NOTE adjust to 30wt% lactic acid conc. before extraction for eff.
                                                   P=(101325, 90000, 70000)) 


    ExtrMixer1 = bst.LiquidsMixingTank(ID='Extraction_M1', ins=[Evaporator_1.outs[0], Organic_Solvent], outs=['mixed_phase_1'])
    PreExtr_Cooler = bst.HXutility('extraction_cooler', ins=ExtrMixer1.outs[0], T=313.15)  # 40°C extr feed for optimal eff.
    ExtrSettler1 = bst.LiquidsSplitSettler(ID='Extraction_S1', ins=[PreExtr_Cooler.outs[0]], outs=['organic_phase_1', 'aq_phase_1'],
                                    split={'L-LacticAcid': 0.99, # Simulate two stage extraction in one
                                            'H2SO4': 0.98,
                                            'Octanol': 1,
                                            'Trioctylamine': 1,
                                            'H2O': 0.01, # some water carry-over
                                            'CaCO3': 0,
                                            'CaSO4_2H2O': 0,
                                            'NH3' : 0}) # NOTE "realistic" assumptions of split

    BackExtrMixer1 = bst.LiquidsMixingTank('BackExtr_Mixer1', ins=[ExtrSettler1.outs[0], Extr_Water_1])
    PreBackExtr_Heater1 = bst.HXutility('backextr_heater1', ins=BackExtrMixer1.outs[0], T=348.15) # ca. 20-30°C higher T for Back Extraction
    BackExtrSettler1 = bst.LiquidsSplitSettler('BackExtr_Settler1',
                                               ins=PreBackExtr_Heater1.outs[0],
                                               outs=('back_extr_aq1', 'back_extr_org1'),
                                               split={'L-LacticAcid': 0.85,
                                                   'H2SO4': 0,
                                                   'H2O': 0.99,
                                                   'Octanol': 0,
                                                   'Trioctylamine': 0})

    BackExtrMixer2 = bst.LiquidsMixingTank('BackExtr_Mixer2', ins=[BackExtrSettler1.outs[1], Extr_Water_2])
    PreBackExtr_Heater2 = bst.HXutility('backextr_heater2', ins=BackExtrMixer2.outs[0], T=348.15) # ca. 20-30°C higher T for Back Extraction
    BackExtrSettler2 = bst.LiquidsSplitSettler('BackExtr_Settler2',
                                               ins=PreBackExtr_Heater2.outs[0],
                                               outs=('back_extr_aq2', 'back_extr_org2'),
                                               split={'L-LacticAcid': 0.8, # 80% of remaining LLA (total 97%)
                                                   'H2SO4': 0,
                                                   'H2O': 0.99,
                                                   'Octanol': 0,
                                                   'Trioctylamine': 0})
    AqPhaseMixer = bst.MixTank('PostExtraction_AqPhase', ins=[BackExtrSettler1.outs[0], BackExtrSettler2.outs[0]], outs='pure_lactic_acid_solution')

# -------------------- SUBSYSTEM 2.2B: SOLVENT RECOVERY & RECYCLING --------------------
#Organic solvent washed (neutralized) with aq. NaOH solution

    Caustic_Mixer = bst.LiquidsMixingTank('Caustic_Mixer', ins=[BackExtrSettler2.outs[1], Caustic_Sol], outs=('caustic_mix'))
    CausticWash_Reactor = bst.SinglePhaseReactor('Caustic_Wash', ins=Caustic_Mixer.outs[0], outs=('neutralized_solvent'),
                                                 T=323.15, P=101325, V_wf=0.8,
                                                 tau=0.5, 
                                                 reaction=caustic_wash_reaction())
    Caustic_Settler = bst.LiquidsSplitSettler('Caustic_Settler', 
                                              ins=CausticWash_Reactor.outs[0], 
                                              outs=('caustic_w_waste_brine', 'recovered_solvent'),
                                              split={
                                                 'Octanol': 0.01,
                                                 'Trioctylamine': 0.01,
                                                 'NaOH': 1,
                                                 'Na2SO4': 1,
                                                 'H2O': 1,
                                                 'L-LacticAcid': 1})
    OrgSolvent_Recycler = bst.Mixer('Solvent_Mixer', ins=[Caustic_Settler.outs[1], OrgSolvent_Makeup], outs=Organic_Solvent)

# MEE 2
    Evaporator_2 = bst.MultiEffectEvaporator(ID='MEE_post_extr', ins=[AqPhaseMixer.outs[0]], outs=('88wt_product', 'condensate'),
    V=0.95, V_definition='Overall', P=(101325, 80000, 60000, 30000)) # NOTE More details required, what pressure? PEP not detailed enough... how many effects? only one flash drum?
    Evaporator_2.split={
        'H2O': 1,
        'LacticAcid': 0,
        'H2SO4': 0,
        'Glucose': 0}

# -------------------- MAIN SYSTEM --------------------
# Combine subsystems into the main system
lactic_acid_system = bst.System('lactic_acid_system',
                                path=[conversion_sys, separation_sys, purification_sys],
                                recycle=[Organic_Solvent])


# SIMULATION 
if __name__ == '__main__':
    lactic_acid_system.simulate()
    print("Simulation completed!")

# ------KPI -------
glucose_feed.show()
CaCO3_feed.show()

feedstock_mixer.outs[0].show()
fermentor.ins[0].show()
fermentor.ins[0].T
fermentor.outs[0].show()  # Liquid
fermentor.outs[1].show()  # Gas

print("--Fermentor PU & HU--")
fermentor.power_utility.show()
fermentor.heat_utilities[0].show()

The output I get for the fermentor duty with different Calcium Lactate Hf values:
Hf = -1930000 --> Chosen arbitrarily to illustrate.
PowerUtility:
consumption: 34.6 kW
production: 0 kW
power: 34.6 kW
cost: 2.71 USD/hr
HeatUtility: chilled_water
duty:-4.57e+04 kJ/hr
flow: 26.7 kmol/hr
cost: 0.202 USD/hr

Hf = -1686100
PowerUtility:
consumption: 46.2 kW
production: 0 kW
power: 46.2 kW
cost: 3.61 USD/hr
HeatUtility: low_pressure_steam
duty: 5.68e+04 kJ/hr
flow: 1.61 kmol/hr
cost: 0.384 USD/hr

As you can see the shift in Hf for CalciumLactate has a big influence.

EDIT: Also note that the duty numbers are quite small, although that could be because of streams being rather small.

EDIT 2: Biomass Hf value is also a knob to make the fermentation more exothermic. I adjusted the Hf value of Biomass_s to match your bioindustrial park lactic acid simulation (from -106800 to -130412 J/mol). That makes the fermentation reaction more exothermic, but not enough to make the fermentor exothermic. I also checked the two reactions above separately for their reaction enthalpies (using rxn.dH) and got the follwing:
Fermentation --> H = -78395.9
Neutralization --> H = 107400.5 (per mole of LA)

Additional thought:
Could this be a limitation on reaction realism in biosteam? As I've read that electrolyte speciation does affect some thermo properties and can distort enthalpies (even to this degree?).

Id really appreciate any support here...

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions