Skip to content

Bound water of CEM I 52.5 N and slag pastes, and the thermogram it integrates to ​

A thermobalance weighs a dried sample while heating it. Between the drying temperature and the end of the run, the loss is the water the hydrates held and the carbon dioxide the carbonates held; the first, per gram of binder, is the bound water . It is read off a state as the heat is: from the amounts of the solids and their formulas, with no reaction to write. The pastes are those of (Gruyaert et al., 2010), whose bound water was measured by thermogravimetry from 378 K (105 °C) to 1123 K (850 °C) under nitrogen, the loss of the decarbonation around 923 K subtracted, once a week in methanol and a week over silica gel had arrested the hydration.

What leaves, and how much ​

The plain paste at 28 months, the cement at the 74 % the image analysis gives, built by scripts/gruyaert2010.jl as on the calorimetry page.

julia
using ChemistryLab, DynamicQuantities, Printf, Plots
include(joinpath(pkgdir(ChemistryLab), "scripts", "gruyaert2010.jl"))

cs = gruyaert_system()
G10(table, column; where...) = only(getproperty(literature_table("Gruyaert2010", table; where...), column))
αc(days, sb) = G10("hydration_degree", :alpha_cement; age_days = days * u"d", slag_to_binder = sb) / 100
αs(days, sb) = sb == 0 ? 0.0 :
    G10("hydration_degree_slag", :alpha_slag; age_days = days * u"d", slag_to_binder = sb) / 100
paste(days, sb) = gruyaert_paste(cs; slag = sb, alpha_cement = αc(days, sb), alpha_slag = αs(days, sb))

opc = paste(852, 0.0)
g(q) = ustrip(uconvert(us"g", q))
loss = ignition_loss(opc.eq)
@printf("certified %s; water %.2f g, CO2 %.2f g per 100 g of binder\n",
        opc.certificate.optimal, g(loss.water), g(loss.carbon_dioxide))
certified true; water 28.00 g, CO2 0.70 g per 100 g of binder

ignition_loss counts hydrogen, not formula water. Portlandite is Ca(OH)₂, has no H₂O written in it, and loses one per formula unit; a rule that searched the formula for H₂O would report none for it. Per phase:

julia
for (ph, m) in bound_water_per_phase(opc.eq)                 # largest first
    g(m) > 0.05 && @printf("  %-15s %5.2f g\n", ph, g(m))
end
per_phase = [(ph, g(m)) for (ph, m) in bound_water_per_phase(opc.eq) if g(m) > 0.05]
p_w = bar(first.(per_phase), last.(per_phase); legend = false, color = :steelblue, xrotation = 30,
    ylabel = "g of water per 100 g of binder", size = (760, 380), left_margin = 6Plots.mm, bottom_margin = 12Plots.mm,
    title = "The plain paste at 28 months, cement 74 % hydrated, w/b 0.5")
plot!(p_w; ylims = (0, 1.1 * ylims(p_w)[2]))

Against the thermobalance ​

The four pastes at the degrees of hydration of the image analysis, and the bound water of (Gruyaert et al., 2010; Fig. 7) at the nearest age, from the batch whose composition is that of the calorimetry (TG/b of Table 1). The image analysis is at 28 months and the thermogravimetry at 1018 days for the pastes with 0 and 50 % slag, at 363 days for the one with 85 %; the extrapolation to infinite time the article fits on each series, , is the other comparison.

julia
wb_measured(sb, days) = only(literature_table("Gruyaert2010", "bound_water";
    batch = "b", slag_to_binder = sb, age_days = days * u"d").bound_water)
wb_inf(sb) = G10("bound_water_infinity", :bound_water_infinity; slag_to_binder = sb)
rows = [(2, 0.0, 1.997), (852, 0.0, 1018.264), (852, 0.5, 1018.264), (852, 0.85, 363.392)]
compared = []
println("  age  s/b    αc    αs   computed (ettringite)  measured (age)   w_b,∞")
for (days, sb, tg) in rows
    p = paste(days, sb)
    p.certificate.optimal || error("uncertified paste")
    aft = g(get(Dict(bound_water_per_phase(p.eq)), "ettringite", 0.0u"kg"))
    push!(compared, (; days, sb, computed = g(bound_water(p.eq)), measured = wb_measured(sb, tg),
                     infinity = days == 2 ? NaN : wb_inf(sb)))
    @printf("%5d  %4.2f  %4.2f  %4.2f   %6.2f  (%5.2f)      %6.2f (%4.0f d)   %s\n", days, sb,
            αc(days, sb), αs(days, sb), g(bound_water(p.eq)), aft, wb_measured(sb, tg), tg,
            days == 2 ? "  —" : @sprintf("%5.1f", wb_inf(sb)))
end
  age  s/b    αc    αs   computed (ettringite)  measured (age)   w_b,∞
    2  0.00  0.54  0.00    21.75  ( 7.44)       11.06 (   2 d)     —
  852  0.00  0.74  0.00    28.00  ( 7.44)       21.43 (1018 d)    23.8
  852  0.50  0.94  0.72    31.56  ( 2.02)       20.19 (1018 d)    22.4
  852  0.85  0.91  0.39    18.65  ( 2.47)       11.49 ( 363 d)    11.4
julia
cases = [@sprintf("%s, %d %% slag", c.days == 2 ? "2 days" : "28 months", round(Int, 100c.sb)) for c in compared]
xs = collect(eachindex(cases))
fig = bar(xs .- 0.27, [c.computed for c in compared]; bar_width = 0.27, label = "computed: every hydrogen of the solids",
          color = :steelblue, xticks = (xs, cases), ylabel = "bound water (g per 100 g of binder)", legend = :topleft,
          size = (860, 420), left_margin = 6Plots.mm, bottom_margin = 6Plots.mm,
          title = "At the degrees of the image analysis")
bar!(fig, xs, [c.measured for c in compared]; bar_width = 0.27, label = "thermobalance, above 105 °C", color = :black)
bar!(fig, xs .+ 0.27, [c.infinity for c in compared]; bar_width = 0.27, label = "w_b,∞ of the article", color = :grey60)
plot!(fig; ylims = (0, 1.2 * ylims(fig)[2]))

The calculation counts every hydrogen of the solids, the thermobalance only what leaves above 105 °C once the sample has been dried, so that the computed water is the larger in the four pastes, by 6.6 to 11.4 g per 100 g of binder, and the gap is not a constant fraction of it: the ratio is 2.0 at 2 days, 1.3 for the plain paste at 28 months and 1.6 for the two blends. In the plain paste the ettringite, which holds all the sulfate at both ages, carries 7.4 g of the computed water; in the blends it carries about 2 g, and the gap lies in the other hydrates. The drop at 85 % slag is found, from 31.6 to 18.7 g where the measurement goes from 20.2 to 11.5 g, whereas the order of the plain paste and of the blend with half slag is not: the blend holds more water than the plain paste in the calculation and less in the measurement. Which part of the water of each hydrate leaves before 105 °C is what a decomposition window per phase would state, and none is published for these pastes; the section below shows how such windows are identified from a measured curve, and what a curve cannot separate.

The shape of the curve, as a method ​

A total is not a thermogram. The curve needs to know when each phase goes, which is not a consequence of a formula; it comes from a publication or from a measurement, and DecompositionWindow makes a curve say which. This section is a self-test of that machinery on a mixture of portlandite, gypsum and calcite, with windows that are neither published nor measured: the target curve below is the model's own output, and nothing in it is a measurement.

julia
using ChemistryLab, DynamicQuantities, Printf

c18 = Dict(symbol(s) => s for s in
           build_species(datapath("cemdata18-thermofun.json"); verbose = false))
aq = Dict(symbol(s) => s for s in
          build_species(datapath("slop98-inorganic-thermofun.json"); verbose = false))

cs = ChemicalSystem(
    vcat([aq[k] for k in ("H2O@", "H+", "Ca+2", "SO4-2", "CO3-2")],
         [c18[k] for k in ("Portlandite", "Gp", "Cal")]),
    [aq[k] for k in ("H2O@", "H+", "Ca+2", "SO4-2", "CO3-2")],
)
i = Dict(symbol(sp) => k for (k, sp) in enumerate(cs.species))
n = Any[fill(1.0e-12u"mol", length(cs.species))...]
n[i["H2O@"]] = ustrip(us"mol", 1.0u"kg" / aq["H2O@"][:M]) * u"mol"
n[i["Portlandite"]] = 1.0u"mol"; n[i["Gp"]] = 2.0u"mol"; n[i["Cal"]] = 3.0u"mol"
state = ChemicalState(cs, n)
loss = ignition_loss(state);
(water = 0.090074999968872 kg, carbon_dioxide = 0.13202699995437428 kg, total = 0.22210199992324628 kg)
julia
placeholder(phase, T½, w; releases = :water) = DecompositionWindow(
    phase, T½, w; releases,
    kind = PROV_PLACEHOLDER, source = "illustrative, not measured",
)
windows = [
    placeholder("Gp", 400.0, 15.0),
    placeholder("Portlandite", 720.0, 12.0),
    placeholder("Cal", 950.0, 20.0; releases = :carbon_dioxide),
]
provenance_report([w.midpoint for w in windows])
(total = 3, counts = Dict{ProvenanceKind, Int64}(PROV_UNSTATED => 0, PROV_ESTIMATED => 0, PROV_FITTED => 0, PROV_PLACEHOLDER => 3, PROV_PUBLISHED => 0, PROV_MEASURED => 0), weakest = PROV_PLACEHOLDER, all_evidence = false, without_uncertainty = 3)

weakest = PROV_PLACEHOLDER is the report doing its job: whatever comes out of a calculation resting on these is a picture, not a measurement.

julia
grid = range(300.0, 1200.0; length = 451)
tg = thermogram(state, windows; temperatures = grid)
@printf("starts at %7.3f g, ends at %7.3f g, loses %7.3f g (ignition_loss says %7.3f)\n",
        ustrip(uconvert(us"g", tg.mass[1] * us"kg")), ustrip(uconvert(us"g", tg.mass[end] * us"kg")),
        ustrip(uconvert(us"g", tg.loss[end] * us"kg")), ustrip(uconvert(us"g", loss.total)))
starts at 718.586 g, ends at 496.576 g, loses 222.102 g (ignition_loss says 222.102)

The curve integrates to what the formulas say, which is the first thing to check and the one a wrong window silently breaks.

A phase with no window never leaves

It contributes to the starting mass and stays there, so a curve computed without noticing integrates to less than ignition_loss — and says nothing about it. phases_without_windows is how to see it — reporting phase => product, because a carbonated hydrate needs two windows and counting per phase would call it covered when half of it is.

julia
phases_without_windows(state, windows[1:2])   # calcite left out
1-element Vector{Pair{String, Symbol}}:
 "Cal" => :carbon_dioxide

The curve and its derivative, each window drawn on its own:

julia
using Plots
p_m = plot(grid, tg.mass_percent; lw = 2, color = :black, legend = false, xlabel = "temperature (K)",
           ylabel = "mass (% of initial)", title = "1 mol CH, 2 mol gypsum, 3 mol calcite", titlefontsize = 10)
vline!(p_m, [400.0, 720.0, 950.0]; ls = :dot, color = :grey50)
p_d = plot(; xlabel = "temperature (K)", ylabel = "−dm/dT (g per K)", title = "Its derivative, which resolves the phases",
           titlefontsize = 10, legend = :topleft)
for (w, lab, c) in zip(windows, ("gypsum", "portlandite", "calcite"), (:steelblue, :firebrick, :seagreen))
    plot!(p_d, grid, 1000 .* thermogram(state, [w]; temperatures = grid).dtg; lw = 2, color = c, label = lab)
end
plot!(p_d, grid, 1000 .* tg.dtg; lw = 2, ls = :dash, color = :black, label = "total")
plot(p_m, p_d; layout = (1, 2), size = (980, 380), left_margin = 6Plots.mm, bottom_margin = 6Plots.mm)

Recovering the windows from the curve ​

Here is why the operator is written as a smooth function of its windows. Given a thermogram, the windows are identifiable, and the machinery that says whether a parameter is determined is the machinery that determines it.

julia
target = tg.dtg       # the model's own curve: a self-test, not a measurement

forward(θ) = thermogram(
    state, with_window_parameters(windows, θ; kind = PROV_UNSTATED);
    temperatures = grid,
).dtg

# Gauss-Newton in log space, on the same sensitivity matrix `identifiability`
# uses — the machinery that says whether a parameter is determined is the
# machinery that determines it.
function identify(θ0; iterations = 40)
    θ = copy(θ0)
    for _ in 1:iterations
        J = log_sensitivity(forward, θ)
        θ = θ .* exp.(clamp.(-(J \ (forward(θ) .- target)), -0.3, 0.3))
    end
    return θ
end

θ0 = window_parameters(windows)[1] .* [1.1, 1.4, 0.945, 0.6, 1.042, 1.4]
θ = identify(θ0)
truth = window_parameters(windows)[1]
@printf("started %4.0f K and %2.0f%% away; recovered to %.2e relative\n",
        maximum(abs.(θ0 .- truth)), 40, maximum(abs.(θ .- truth) ./ truth))
started   40 K and 40% away; recovered to 0.00e+00 relative

And then the question that matters more than the numbers:

julia
id = identifiability(forward, θ; observed = target,
                     names = window_parameters(windows)[2])
id
Identifiability of 6 parameters
  singular values  [0.181, 0.0641, 0.0403, 0.00691, 0.00436, 0.00122]
  condition        148.7
  constrained      6 of 6 directions
  residual RMSE    0.0
  noise level σ    7.146e-12
  most collinear   w(Portlandite) / w(Cal)  r = -0.001

Six parameters, six directions constrained — because the three peaks are well separated and the curve is fitted exactly, so that the noise level takes its floor, √eps times the root-mean-square of the curve, and the standard error of every direction is far below one. That is not the usual case. The spectrum does fall by a factor of 5.8 between its third and fourth values: a log-sensitivity carries the size of its parameter, some hundreds of kelvin for a midpoint and some tens for a width. Read off that gap alone, without a noise level, the rank would have been three.

Where it stops, and why that is the useful part ​

julia
overlapped = [placeholder("Gp", 400.0, 15.0), placeholder("Portlandite", 405.0, 15.0)]
fwd2(θ) = thermogram(state, with_window_parameters(overlapped, θ; kind = PROV_UNSTATED);
                     temperatures = grid).dtg
θ2, names2 = window_parameters(overlapped)
identifiability(fwd2, θ2; names = names2)
Identifiability of 4 parameters
  singular values  [0.0659, 0.00621, 0.000718, 0.000188]
  condition        350.9
  constrained      1 of 4 directions, by the gap (no noise level)
  most collinear   T½(Gp) / T½(Portlandite)  r = -0.995
julia
p_o = plot(; xlims = (300, 520), xlabel = "temperature (K)", ylabel = "−dm/dT (g per K)", legend = :topright,
           size = (720, 360), left_margin = 6Plots.mm, bottom_margin = 6Plots.mm,
           title = "Gypsum at 400 K and portlandite at 405 K, both 15 K wide")
for (w, lab, c) in zip(overlapped, ("gypsum", "portlandite"), (:steelblue, :firebrick))
    plot!(p_o, grid, 1000 .* thermogram(state, [w]; temperatures = grid).dtg; lw = 2, color = c, label = lab)
end
plot!(p_o, grid, 1000 .* fwd2(θ2); lw = 2, ls = :dash, color = :black, label = "what the curve shows")
p_o

Two phases releasing 5 K apart, and the rank comes out at one of four: the position of what the curve shows as one peak, the spectrum falling by 10.6 after its first value and by 8.7 after its second. The two midpoints are correlated at −0.995, so that position is a combination of both, and two peaks with four parameters between them are not there to be seen. A fit would still return four numbers.

This is the ordinary case in a cement paste — C-S-H, AFt and AFm all release below 200 °C — which is why running identifiability on the windows is not a formality.

julia
null_participation(identifiability(fwd2, θ2; names = names2))
4-element Vector{Float64}:
 0.056358824666536474
 0.9999989077571443
 0.9436599372207309
 0.9999823303555877

That is what as_traced reads to decide which parameters to report as fitted and which as PROV_PLACEHOLDER — a number the curve did not determine is one the optimizer had to leave somewhere. It reads the subspace, not the parameter's position relative to the rank: the rank counts directions, the position is the packing order, and confusing them flags whichever of a trading-off pair happened to be listed second.

What a thermogram is plotted against, and windows given as intervals ​

A thermobalance records the mass of the sample as it is heated (Lever et al., 2014). What the cement literature reports is the mass lost, in percent of a reference mass, and the papers co-authored by Lothenbach, whose method is set out in Lothenbach et al. (2016), do not all take the same reference. Three are in use, and the data files of this package carry each with its source:

reference massused byrelative_to
the sample at the start of the runthe thermograms of Deschner et al. (2012):initial (default)
the dry sample at a temperature500 °C, (Schöler et al., 2015); 800 °C, (Shi et al., 2016)that temperature
the sample ignited at 980 °CShi and Lothenbach (2020)980 + 273.15, or :ignited

thermogram returns the mass and the loss in kilograms, and both again in percent of the reference relative_to names (mass_percent, loss_percent, the loss counted from the first temperature of the grid). The reference mass is computed at its own temperature, not read off the nearest point of the grid.

julia
dry = literature_value("Scholer2015", "tga_dry_solids_temperature")   # 500 °C
for (label, ref) in ((":initial", :initial), ("dry at 500 °C", dry), (":ignited", :ignited))
    t = thermogram(state, windows; temperatures = grid, relative_to = ref)
    @printf("%-14s reference %7.3f g   loss at the end %6.2f %%\n",
            label, ustrip(uconvert(us"g", t.reference_mass * us"kg")), t.loss_percent[end])
end
:initial       reference 718.586 g   loss at the end  30.90 %
dry at 500 °C  reference 628.796 g   loss at the end  35.31 %
:ignited       reference 496.576 g   loss at the end  44.71 %
julia
fig = plot(; xlabel = "temperature (K)", ylabel = "mass lost (% of the reference)", legend = :topleft,
           size = (720, 380), left_margin = 6Plots.mm, bottom_margin = 6Plots.mm,
           title = "One curve, three reference masses")
for (label, ref) in ((":initial", :initial), ("dry at 500 °C", dry), (":ignited", :ignited))
    plot!(fig, grid, thermogram(state, windows; temperatures = grid, relative_to = ref).loss_percent; lw = 2, label)
end
fig

The same loss is a larger percentage of a smaller reference: which convention a measured curve follows has to be read in its paper before it is compared with one computed here.

The papers also attribute a loss to a phase by a temperature interval: portlandite between 350 and 500 °C (De Weerdt et al., 2011), the carbonate from about 300 to 850 °C (Shi et al., 2016), the water of ettringite between 30 and 150 °C (Möschner et al., 2009), the total bound water between 30 and 550 °C (L'Hôpital et al., 2016). A window can be given that way, between = (T₁, T₂): the phase then releases everything inside the interval and nothing outside it, along a smooth step whose rate vanishes at both ends. literature_windows gives such a window for every release of a state at once, from these four papers: the water of portlandite and of the ettringites over their own intervals, that of every other hydrate over the interval of the total bound water, the carbon dioxide over that of the carbonate.

julia
ch = literature_table("DeWeerdt2011", "tga_intervals")
cc = literature_table("Shi2016", "tga_intervals")
from_papers = [
    windows[1],      # gypsum: no interval in these papers, the placeholder kept
    DecompositionWindow("Portlandite"; between = (only(ch.lower), only(ch.upper)),
                        kind = PROV_PUBLISHED, source = "DeWeerdt2011"),
    DecompositionWindow("Cal"; between = (only(cc.lower), only(cc.upper)),
                        releases = :carbon_dioxide, kind = PROV_PUBLISHED, source = "Shi2016"),
]
T1, T2 = window_interval(from_papers[2])
t = thermogram(state, from_papers; temperatures = [T1, T2], relative_to = dry)
inside(phase) = t.by_phase[phase][2] - t.by_phase[phase][1]
@printf("lost between %.0f and %.0f °C: %.3f g of portlandite water, %.3f g of calcite CO2\n",
        T1 - 273.15, T2 - 273.15, 1000 * inside("Portlandite"), 1000 * inside("Cal"))
@printf("portlandite holds %.3f g of water\n",
        ustrip(uconvert(us"g", Dict(bound_water_per_phase(state))["Portlandite"])))
lost between 350 and 500 °C: 18.015 g of portlandite water, 36.603 g of calcite CO2
portlandite holds 18.015 g of water

The interval gives the portlandite all its water and nothing else. The loss read over that interval holds more: the carbonate's interval overlaps it, and part of the calcite's carbon dioxide leaves between 350 and 500 °C as well, which is the loss a reading has to separate (L'Hôpital et al. (2016) quantify portlandite from the loss around 450 °C by the tangent method). A logistic window spread over the same interval, 1 % released at one end and 99 % at the other (window_interval), would put 2 % of the portlandite's water outside it. Both forms can be mixed in one set, fitted to a curve (window_parameters gives an interval's two ends), and read by bound_water over a temperature range, as a recipe's bound water is compared with a measurement.

See also ​