Skip to content

Recipes: materials, extents and what has not reacted ​

A cement calculation starts from materials (a clinker, a slag, a limestone, water), their masses, and how much of each has reacted at the age of interest. What has reacted enters the equilibrium; what has not stays in the paste as a solid that takes up volume and holds matter, without taking part in the chemistry. Written by hand, this bookkeeping is repeated on every page and each copy can drift from the others. This page describes the layer that does it once: Material, Recipe, budget and the processes built on them.

Materials ​

A material is a list of constituents, each with its mass fraction of the material and its own degree of reaction:

  • a MineralConstituent is a database phase (alite, calcite, quartz);

  • an OxideConstituent is known by its oxide analysis only, like the glass of a slag, and enters the equilibrium through its elements.

Published materials are built from data/recipe_templates.toml, which names the literature file each number is read from. The system below is a small paste: the clinker phases, the usual hydrates and CSHQ.

julia
using ChemistryLab, OptimaSolver, DynamicQuantities
substances = build_species(datapath("cemdata18-thermofun.json"); verbose = false)
db = Dict(symbol(s) => s for s in substances)
pure = split("C3S C2S C3A C4AF Gp Anh Cal Portlandite ettringite monosulphate12 " *
             "monocarbonate hemicarbonate C4AH13 C3AH6 C3FH6 straetlingite " *
             "hydrotalcite Mg2AlC0.5OH Brc FeOOHmic AlOHmic Amor-Sl K2SO4")
csh = ["CSHQ-JenD", "CSHQ-JenH", "CSHQ-TobD", "CSHQ-TobH", "KSiOH", "NaSiOH"]
species = speciation(substances, vcat(pure, csh); aggregate_state = [AS_AQUEOUS],
                     exclude_species = split("H2@ O2@ CH4@"))
cs = ChemicalSystem(species, CEMDATA_PRIMARIES;
                    solid_solutions = [SolidSolutionPhase("CSHQ", [db[m] for m in csh])])
material_templates()
22-element Vector{String}:
 "PC (Durdzinski 2017), phases"
 "PC (Durdzinski 2017), Bogue"
 "S1 slag (Durdzinski 2017)"
 "S2 slag (Durdzinski 2017)"
 "calcareous fly ash (Durdzinski 2017)"
 "siliceous fly ash (Durdzinski 2017)"
 "clinker (De Weerdt 2011)"
 "siliceous fly ash (De Weerdt 2011)"
 "limestone (De Weerdt 2011)"
 "white Portland cement (Shi 2016)"
 "limestone (Shi 2016)"
 "metakaolin (Shi 2016)"
 "CEM I 52.5 N (Gruyaert 2010), Bogue"
 "CEM I 52.5 N (Lavergne 2018)"
 "blast-furnace slag (Gruyaert 2010)"
 "OPC (Schöler 2015)"
 "blast-furnace slag (Schöler 2015)"
 "siliceous fly ash (Schöler 2015)"
 "limestone (Schöler 2015)"
 "PC (Snellings 2022)"
 "slag (Snellings 2022)"
 "limestone (Snellings 2022)"

This list is written out by hand. The phases a paste may form are a modeling choice, and data/phase_lists.toml records that choice for published pastes, each list after the paper it names. phase_list_system builds a system from one, and the processes page uses it.

The Portland cement of the RILEM round robin (Durdziński et al., 2017) by Bogue's calculation on its oxide analysis (bogue): four clinker phases and gypsum, and the oxides no phase takes as one oxide constituent.

julia
pc = material_template("PC (Durdzinski 2017), Bogue", cs)
[(c.name, round(c.mass_fraction; digits = 3)) for c in pc.constituents]
6-element Vector{Tuple{String, Float64}}:
 ("C3S", 0.601)
 ("C2S", 0.1)
 ("C3A", 0.09)
 ("C4AF", 0.11)
 ("Anh", 0.054)
 ("minor oxides", 0.036)

How much has reacted ​

An extent is a number between 0 and 1, a function of time, or one of ConstantExtent, TabulatedExtent (measured degrees of reaction, interpolated), LogisticExtent, ParrottKillohExtent (the rate law of Parrott and Killoh (1984) integrated) and CappedExtent (any of them under Powers' water limit (Powers, 1948)). with_extents sets them by constituent, and material_extent sets one for the material as a whole; the effective extent of a constituent is the product of the two (effective_extent). A glass described by its oxides alone has one extent, the material's.

julia
pc = with_extents(pc, Dict("C3S" => 0.80, "C2S" => 0.45, "C3A" => 0.90, "C4AF" => 0.60))
slag = with_extents(material_template("S1 slag (Durdzinski 2017)", cs), Dict{String, Any}();
                    material_extent = 0.40)

A recipe, and what it puts into the equilibrium ​

A Recipe gives the binder's mass fractions and the water/binder ratio. Every amount is for 100 g of binder unless binder_mass says otherwise, so a mass read off the answer is in grams per 100 g of binder.

julia
recipe = Recipe(pc => 0.7, slag => 0.3; w_b = 0.45)
bud = budget(recipe, cs)
[(x.constituent, round(x.mass; digits = 2), x.reason) for x in bud.residual]
8-element Vector{Tuple{String, Float64, Symbol}}:
 ("C3S", 8.41, :unreacted)
 ("C2S", 3.84, :unreacted)
 ("C3A", 0.63, :unreacted)
 ("C4AF", 3.07, :unreacted)
 ("minor oxides: TiO2", 0.21, :not_in_system)
 ("minor oxides: P2O5", 0.14, :not_in_system)
 ("minor oxides: Mn2O3", 0.07, :not_in_system)
 ("S1 slag (Durdzinski 2017)", 18.0, :unreacted)

The rules budget applies:

whatwhere it goes
the reacted part of a mineral constituentthe equilibrium, as that species
the reacted part of an oxide constituentthe equilibrium, as its elements (oxide_budget)
water, saltsthe equilibrium, always completely
the unreacted part of a constituentthe residue: its mass, its volume, its enthalpy
an oxide whose element the system has no primary forthe residue, with reason = :not_in_system

The residue keeps its volume from the molar volume of its species or from the density of an oxide constituent, and its enthalpy from the enthalpy of formation. A glass has no formula, so no database gives either: unless its source does, they are nothing, and a volume or a heat that needs them is reported missing (NaN), never estimated.

The answer ​

equilibrate_certified, given a recipe and a system, solves the budget and returns a RecipeState.

julia
model = cemdata18_activity_model(:KOH)
rs, cert = equilibrate_certified(recipe, cs; model)
cert.optimal
true

What is read off it counts the residue where it belongs. phase_masses lists the solids, the unreacted parts last:

julia
first(phase_masses(rs), 6)
6-element Vector{Pair{String, Float64}}:
      "CSHQ-JenD" => 21.56277723876418
 "monosulphate12" => 19.376214720963333
    "Portlandite" => 14.082296986930002
      "CSHQ-JenH" => 13.69123573577595
      "CSHQ-TobD" => 11.557544584640926
   "hydrotalcite" => 5.554775425684819

porosity is referred to the initial volume, that of the reactants, the water and the residue:

the void being the chemical shrinkage the reactions did not refill. With a slag glass of unknown density, the residue's volume is missing and the porosity cannot be computed: NaN, which is the honest answer. volume_fractions of a recipe state divides the same initial volume among the phases, the unreacted parts and the void, and is refused for the same reason. bound_water is per gram of binder and pore_solution gives the pH and the dissolved elements, in mol per kg of water.

julia
bound_water(rs), pore_solution(rs).pH
(0.2502762429266569, 13.572219786079398)

Bound water and heat ​

bound_water is the water the solids would lose on ignition (ignition_loss), with that of an unreacted mineral such as gypsum, over the binder mass. It is the whole of what a thermogram integrates to. A thermogravimetric reading taken between 105 °C and 550 °C, a common choice, leaves part of it out, so the two are compared only over the same temperature window: bound_water(rs; window = (T₁, T₂), windows) counts what the solids release between the two temperatures, from a DecompositionWindow for each solid that holds water, and refuses a solid without one. The thermogram page shows where such windows come from.

At constant temperature and pressure, the heat a paste releases between two states is the fall of its enthalpy:

heat_release, given two states of one paste, computes it. The residue counts by what changed. The unreacted part of a clinker phase has the enthalpy of formation of its database record, so its change is counted. An oxide the system cannot hold, such as the titanium of this cement, keeps the same mass in both states and adds nothing, although no enthalpy is known for it. Where the unreacted part of a constituent without a sourced enthalpy changes, the heat is NaN rather than a number that leaves it out. The slag glass of this recipe is such a constituent, and enthalpy of the whole paste is NaN for the same reason.

A glass can be given an enthalpy of formation with glass_enthalpy, built from measurements on silicate glasses and stating what it assumes, which The enthalpy of a glass sets out:

julia
g = glass_enthalpy(oxides; ignore = ("P2O5",))     # an oxide with no crystal, by name
OxideConstituent("glass", oxides; enthalpy = g.enthalpy, source = "glass_enthalpy")

g.span and g.uncertainty say how far that enthalpy can be trusted, and g.unassigned how much of the glass the measured glasses could not take.

Below, the cement alone at two sets of extents, both chosen for the illustration, gives the heat released from the first to the second, in joules per gram of cement:

julia
early = with_extents(pc, Dict("C3S" => 0.40, "C2S" => 0.10, "C3A" => 0.50, "C4AF" => 0.30))
rs1, _ = equilibrate_certified(Recipe(early => 1.0; w_b = 0.45), cs; model)
rs2, _ = equilibrate_certified(Recipe(pc => 1.0; w_b = 0.45), cs; model)
(slag_paste = enthalpy(rs), heat = heat_release(rs1, rs2) / 100)
(slag_paste = NaN, heat = 177.71677814575335)

Processes ​

A process is a sequence of equilibria, each started from the previous answer, returned as a ProcessResult that process_table turns into a table:

  • hydrate: the recipe at several ages, its extents read at each;

  • blend: part of the binder replaced by a material, at several fractions;

  • titrate, carbonate, add_salt: a species added to the budget in steps;

  • leach: the pore solution replaced by water, step after step.

julia
co = carbonate(rs, [0.0, 0.2, 0.5])
process_table(co; phases = ["Portlandite", "Cal"])
3×7 DataFrame
RowCO2@certifiedpHporositybound_waterPortlanditeCal
AnyBoolFloat64Float64Float64Float64Float64
10.0true13.5722NaN0.25027614.08230.0
20.2true13.5444NaN0.2475710.017.8901
30.5true10.6604NaN0.1277130.050.0429

From a recipe to a kinetic run ​

hydrate imposes the extents. Rate laws can decide them instead: a KineticsProblem built from the recipe takes each constituent named in rates whole and unreacted, as a kinetic species that dissolves into the primaries of the system, and every other constituent as the recipe has it at the start of the run. The same recipe then drives both.

julia
rates = Dict(
    "C3S" => parrott_killoh_avrami(PK84_PARAMS_C3S, "C3S"),
    "C2S" => parrott_killoh_avrami(PK84_PARAMS_C2S, "C2S"),
)
kp = KineticsProblem(Recipe(pc => 1.0; w_b = 0.45), cs, rates, (0.0, 28 * 86400.0))
for kr in kp.kinetic_reactions
    println(kr.reaction)
end
(CaO)₃SiO₂ = 3H₂O@ + SiO₂@ + 3Ca²⁺ + (-6)H⁺
(CaO)₂SiO₂ = 2H₂O@ + SiO₂@ + 2Ca²⁺ + (-4)H⁺

The dissolutions are those the conservation matrix of the system gives, so they conserve every element by construction. The reacted part of a constituent known by its oxides (the alkalis and the minor oxides of a clinker) enters as the primaries that carry its elements; its heat of dissolution is not part of the heat of the run. A glass has no formula to dissolve: glass_species gives it one from its own oxides, and with_species puts that species in its place, after which it can be given a rate (What a recipe puts into the equilibrium, Section 6). with_glass_species does both for every glass a dictionary of rates names, and adds the species to the system, with an enthalpy of formation estimated from the oxides so that the heat of the glass counts in a calorimeter. integrate then runs the problem, with an equilibrium solver for the partial equilibrium of the products, as in the kinetics tutorial.

Where to go next ​

The equations behind this page, the budget, the mass balance and what a kinetic constituent changes in them, are written in What a recipe puts into the equilibrium.

The rate laws a recipe attaches to its constituents are written as described in Writing a kinetic model, and the run itself is the one of the tutorial Chemical Kinetics. The blended binders use recipes throughout, from CEM II/A-LL and CEM II/B-S onwards.