Chloride binding in CEM III/A and CEM III/B
Before this page
Chloride binding by C-S-H and Friedel's salt and CEM III/A, a blastfurnace cement, whose paste this page salts.
A blended cement binds the chloride that enters it in its AFm phases, as Kuzel's and Friedel's salts, and in its C-S-H. The salts are phases of CEMDATA18 (Lothenbach et al., 2019). The C-S-H is its CSHQ solid solution, whose end members hold calcium and alkalis but no chloride. This page salts the CEM III/A paste of the blastfurnace cement page along two routes that give the C-S-H its share, then a CEM III/B paste without portlandite, along the one route that applies there.
Route A keeps the surface model of the C-S-H that Guo et al. (2018) use: silanol sites that bind calcium, alkalis and chloride. It puts them on a C-S-H whose amount and composition a first equilibrium with CSHQ has decided, and which
freeze_solid_solutionthen sets aside.Route B adds an end member to CSHQ,
CSHQ-Cl= (CaCl₂)₀.₅, whose Gibbs energy was fitted on the sorption tests of Hirao et al. (2005). It ships incemdata18-chloride.json, and the phase isCSHQ_Cl.
What these calculations are, and what they are not
Neither route says where the chloride sits in the gel. Plusquellec and Nonat (2016) found that chloride does not adsorb specifically on C-S-H. What a depletion measurement counts as bound accompanies the calcium the surface adsorbs, in the diffuse layer that screens it. The surface complex of route A and the end member of route B are both effective descriptions of that uptake.
Route A freezes the gel at the composition of the chloride-free paste. That is a sound approximation only while portlandite fixes the activities of calcium and hydroxide, and
freeze_solid_solutionrefuses a paste without it.The end member of route B rests on three points of one gel at portlandite saturation. The fit is within 0.05 mmol/g at 0.5 and 1 mol/L, and four times too high at 0.1 mol/L. Its dependence on the Ca/Si is the model's: the end member was chosen to follow the trend measured by Zibara et al. (2008), not fitted to it (
data/chloride/README.md). It is fitted on NaCl solutions only: in a CEM II concrete, a CaCl₂ solution binds two to three times as much chloride at the same free chloride (Tran et al., 2018), which the end member does not reproduce.The B-dot activity model (Helgeson, 1969) of the CEMDATA18 pages holds up to an ionic strength of about 1 mol/kg. No solution below exceeds 0.7.
using ChemistryLab, DynamicQuantities, JSON, OptimaSolver, OrderedCollections, Printf, Plots
substances = build_species(datapath("cemdata18-chloride.json"); verbose = false)
byname = Dict(symbol(s) => s for s in substances)
# The recipe of the CEM III page, with its assumptions: half clinker, half slag;
# the clinker of Lavergne2018, Table 9; a European slag analysis; the alkalis at
# a usual industrial level; w/b 0.40; the clinker reacted to the water ceiling
# and the slag to the mean of the round robin of Durdzinski2017 at 28 days.
bogue = literature_table("Lavergne2018", "cement_bogue")
CLINKER = OrderedDict(zip(bogue.phase, bogue.percent ./ 100))
SLAG = Dict("CaO" => 0.41, "SiO2" => 0.36, "Al2O3" => 0.11, "MgO" => 0.08, "SO3" => 0.02)
ALKALIS = Dict("K2O" => 0.008, "Na2O" => 0.002)
WB, BINDER_G = 0.40, 100.0
sem(material, age) = literature_table("Durdzinski2017", "degree_of_reaction";
technique = "SEM-IA", material, curing = "sealed", age_days = age).degree_percent
ALPHA_CLINKER = powers_alpha_max(WB)
ALPHA_SLAG = min(sum([sem("S1", 28); sem("S2", 28)]) / 400, ALPHA_CLINKER)
# The activity model Cemdata18 prescribes (its Eq. C.1): extended Debye-Hückel,
# with the common ion size and B-dot the paper gives for KOH solutions (it also
# gives them for NaOH). The clinker's alkalis are mostly potassium.
model = cemdata18_activity_model(:KOH)
# The molar K/Na ratio of its alkalis, which is why the KOH set applies.
Mox(ox) = ustrip(us"g/mol", Species(ox)[:M])
println("molar K/Na of the alkalis: ", round((2 * ALKALIS["K2O"] / Mox("K2O")) / (2 * ALKALIS["Na2O"] / Mox("Na2O")); digits = 1))
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 Tro Py Sulfur Mgs"
)
redox = ["HS-", "H2S@", "SO4-2", "SO3-2", "S2O3-2", "O2@", "H2@"]
salts = ["C4AClH10", "C4AsClH12"]
phases = build_solid_solutions(datapath("solid_solutions.toml"), byname)
phase(n) = only(p for p in phases if name(p) == n)
hydrogarnet = phase("C3(AF)S0.84H")
members(p) = symbol.(end_members(p))
molar(s) = ustrip(us"g/mol", byname[s][:M])
M_Cl = ustrip(us"g/mol", calculate_molar_mass(Dict(:Cl => 1)))
"""
The reacted paste per 100 g of binder, with `dose` percent of chloride by mass of
binder added as NaCl: the state that carries the clinker and the water, and the
budget that adds the alkalis and the slag, on the components of `cs`.
"""
function paste(cs; dose = 0.0, clinker_fraction = 0.5)
st = ChemicalState(cs)
for (ph, frac) in CLINKER
set_quantity!(st, ph, ALPHA_CLINKER * BINDER_G * clinker_fraction * frac / molar(ph) * u"mol")
end
set_quantity!(st, "H2O@", BINDER_G * WB / molar("H2O@") * u"mol")
n_cl = dose / 100 * BINDER_G / M_Cl
if n_cl > 0
set_quantity!(st, "Na+", n_cl * u"mol")
set_quantity!(st, "Cl-", n_cl * u"mol")
end
b = budget(st)
b .+= oxide_budget(ALKALIS, cs.SM.primaries; mass = BINDER_G * clinker_fraction * ALPHA_CLINKER * u"g")
b .+= oxide_budget(SLAG, cs.SM.primaries; mass = BINDER_G * (1 - clinker_fraction) * ALPHA_SLAG * u"g")
return (; state = st, b)
endmolar K/Na of the alkalis: 2.6The paste, before any chloride
The first equilibrium is that of the CEM III page, recomputed. The paste holds portlandite, and its C-S-H holds part of the alkalis, which route A returns to the solution when it freezes the gel.
cshq = phase("CSHQ")
sp1 = speciation(substances, vcat(pure, members(cshq), members(hydrogarnet), redox);
aggregate_state = [AS_AQUEOUS])
cs1 = ChemicalSystem(sp1, CEMDATA_PRIMARIES; solid_solutions = [cshq, hydrogarnet])
p1 = paste(cs1)
eq1, cert1 = equilibrate_certified(p1.state; model, b = p1.b)
gel = solid_solution_totals(eq1, "CSHQ")
mol(eq, s) = ustrip(us"mol", moles(eq, byname[s]))
water(eq) = mol(eq, "H2O@") * molar("H2O@") / 1000
@printf("certified %s, pH %.3f, portlandite %.4f mol, pore water %.1f g\n",
cert1.optimal, pH(eq1, model), mol(eq1, "Portlandite"), 1000water(eq1))
@printf("C-S-H: %.4f mol Si, Ca/Si %.3f, holding %.4f mol Na and %.4f mol K\n",
gel.elements[:Si], gel.elements[:Ca] / gel.elements[:Si], gel.elements[:Na], gel.elements[:K])certified true, pH 13.054, portlandite 0.1084 mol, pore water 15.7 g
C-S-H: 0.2876 mol Si, Ca/Si 1.610, holding 0.0028 mol Na and 0.0060 mol KRoute A: the published surface on a frozen gel
The second system carries the hydrates, the chloride salts and the aqueous species of the first, and the sites. The C-S-H is no longer one of its phases, so the support of the sites is declared external: it belongs to a solid outside the system, which is what allows sites that bind calcium beside the calcium of the hydrogarnet solid solution. The sites and their area are referred to the silicon of the frozen gel, through the molar mass per silicon of the C-S-H of Guo et al. (2018): 0.004 mol/g and 500 m²/g, times 191.4 g/mol. The gel of the first stage then carries as many sites per silicon as that of Guo et al. (2018) does. The alkalis of NaSiOH and KSiOH go back to the solution as hydroxides.
sp2 = speciation(substances, vcat(pure, salts, members(hydrogarnet), redox, ["Na+", "K+", "Cl-"]);
aggregate_state = [AS_AQUEOUS])
guo(q) = literature_value("Guo2018", q)
t = literature_table("Guo2018", "csh_formula")
per_si = Dict(zip(t.oxide, t.coefficient ./ t.coefficient[findfirst(==("SiO2"), t.oxide)]))
M_guo = sum(k * ustrip(us"g/mol", Species(ox)[:M]) for (ox, k) in per_si)
n_sites = ustrip(u"mol/g", guo("csh_site_density")) * M_guo * gel.elements[:Si]
area = ustrip(u"m^2/g", guo("csh_specific_surface_area")) * M_guo * gel.elements[:Si]
rows = literature_table("Guo2018", "surface_reactions_phreeqc")
aqueous = [s for s in sp2 if aggregate_state(s) == AS_AQUEOUS]
family = site_family(
"Csh_w", [eq => lk for (eq, lk) in zip(rows.reaction, rows.log_K)], aqueous;
master = "Csh_w", site = "Xw", capacity = TotalSiteAmount(n_sites * u"mol"),
support = SurfaceSupport("C-S-H", FixedSurfaceArea(area); external = true), model = DiffuseLayer(; area),
)
sites = vcat([family.free_site], family.complexes)
cs2 = ChemicalSystem(vcat(sp2, sites),
vcat([s for s in sp2 if symbol(s) in CEMDATA_PRIMARIES], [family.free_site]);
solid_solutions = [hydrogarnet], site_families = [family])
frozen = freeze_solid_solution(eq1, "CSHQ", cs2; buffer = "Portlandite")
@printf("%.3f mol of sites on %.3g m², for %.1f g of pore water: a film %.2f nm thick\n",
n_sites, area, 1000water(eq1), 1.0e9 * water(eq1) / 1000 / area)0.220 mol of sites on 2.75e+04 m², for 15.7 g of pore water: a film 0.57 nm thickAt that area the pore solution is a film 0.57 nm thick on the gel, thinner than the diffuse layer that screens the surface. The ions of the layer cannot be counted by a DonnanLayer, which assumes a free solution beyond it, and the Gouy-Chapman potential is itself an extrapolation here. The layer's ions are left implicit, as PHREEQC's default leaves them.
The paste is then salted with up to 0.4 % of chloride by mass of binder, added as NaCl, and solved from the chloride-free point up, each solve starting from the last. The table gives the free chloride (mol/kg of pore water), the ionic strength, the pH, the portlandite, and the chloride in each place (mol per 100 g of binder): two per Friedel's salt, one per Kuzel's salt, one per surface complex.
DOSES = [0.0, 0.05, 0.1, 0.2, 0.4] # percent of chloride by mass of binder
idx2 = Dict(symbol(s) => i for (i, s) in enumerate(cs2.species))
function salted(dose)
n = ustrip.(us"mol", frozen.state.n)
n[idx2["XwOH"]] = n_sites
n_cl = dose / 100 * BINDER_G / M_Cl
n[idx2["Na+"]] += n_cl
n[idx2["Cl-"]] += n_cl
return ChemicalState(cs2, n .* u"mol")
end
A2 = Float64.(cs2.SM.A)
route_a, certs_a = equilibrate_path(salted(0.0), [A2 * ustrip.(us"mol", salted(d).n) for d in DOSES]; model)
println("certified: ", count(c -> c.optimal, certs_a), " of ", length(certs_a))
chlorine(cs) = [Float64(get(atoms(s), :Cl, 0)) for s in cs.species]
function partition(eq, cs, dose; gel_species)
n = ustrip.(us"mol", eq.n)
cl = chlorine(cs)
free = sum(cl[i] * n[i] for i in cs.idx_aqueous)
added = dose / 100 * BINDER_G / M_Cl
at(s) = n[findfirst(x -> symbol(x) == s, cs.species)]
return (; free_molal = free / water(eq), friedel = 2at("C4AClH10"), kuzel = at("C4AsClH12"),
gel = at(gel_species), bound = added > 0 ? 1 - free / added : 0.0,
CH = at("Portlandite"), pH = pH(eq, model), I = ionic_strength(eq))
end
function report(states, cs, gel_species)
println(" Cl % free Cl I pH CH Friedel Kuzel C-S-H bound")
for (d, eq) in zip(DOSES, states)
r = partition(eq, cs, d; gel_species)
@printf("%5.2f %7.4f %5.3f %6.3f %.4f %.5f %.5f %.5f %5.1f %%\n",
d, r.free_molal, r.I, r.pH, r.CH, r.friedel, r.kuzel, r.gel, 100r.bound)
end
end
report(route_a, cs2, "XwOHCl-")certified: 5 of 5
Cl % free Cl I pH CH Friedel Kuzel C-S-H bound
0.00 0.0000 0.287 13.197 0.0213 0.00000 0.00000 0.00000 0.0 %
0.05 0.0193 0.320 13.206 0.0213 0.00000 0.00087 0.00018 74.0 %
0.10 0.0202 0.345 13.225 0.0211 0.00000 0.00226 0.00018 86.4 %
0.20 0.0220 0.392 13.259 0.0207 0.00000 0.00505 0.00018 92.7 %
0.40 0.0252 0.481 13.316 0.0200 0.00000 0.01064 0.00018 95.9 %Two numbers belong to the model rather than to the paste. Before any chloride is added, the surface binds 0.087 mol of calcium, taken from the portlandite: 80 % of it. The pH rises from 13.05 to 13.20 for that reason alone. The C-S-H of the first stage already held that calcium in its Ca/Si. The published model counts calcium on the surface on top of a bulk composition that, measured, includes it, and freezing the gel keeps that assumption. The portlandite nonetheless remains at every dose, so the condition the frozen gel rests on holds.
The chloride goes to Kuzel's salt, which holds half a sulfate and one chloride per formula unit. At 0.4 % it holds 0.0106 mol, and the 0.0056 mol of monosulfate of the first stage carry sulfate for 0.0112. The surface holds 0.00018 mol from the lowest dose on: monosulfate and Kuzel's salt coexist and fix the chloride activity, as in the one-stage paste. No Friedel's salt forms at these doses.
with_csh_surface runs this route on the equilibrium of a recipe in one call: the gel frozen, the sites and the area of Guo et al. (2018) referred to its silicon, the paste solved again, and what the surface holds.
Route B: chloride in the solid solution
Route B needs no second stage: the paste and the salt are equilibrated together, with CSHQ_Cl in place of CSHQ. The phase is read from data/solid_solutions.toml like the others; with CEMDATA18 alone its entry is skipped, since its end member is not there.
cshq_cl = phase("CSHQ_Cl")
spB = speciation(substances, vcat(pure, salts, members(cshq_cl), members(hydrogarnet), redox, ["Cl-"]);
aggregate_state = [AS_AQUEOUS])
csB = ChemicalSystem(spB, CEMDATA_PRIMARIES; solid_solutions = [cshq_cl, hydrogarnet])
AB = Float64.(csB.SM.A)
route_b, certs_b = equilibrate_path(paste(csB).state, [paste(csB; dose = d).b for d in DOSES]; model)
println("certified: ", count(c -> c.optimal, certs_b), " of ", length(certs_b))
report(route_b, csB, "CSHQ-Cl")certified: 5 of 5
Cl % free Cl I pH CH Friedel Kuzel C-S-H bound
0.00 0.0000 0.150 13.054 0.1084 0.00000 0.00000 0.00000 0.0 %
0.05 0.0131 0.165 13.057 0.1084 0.00000 0.00057 0.00063 85.4 %
0.10 0.0141 0.176 13.086 0.1085 0.00000 0.00197 0.00063 92.2 %
0.20 0.0163 0.203 13.146 0.1085 0.00000 0.00475 0.00064 95.5 %
0.40 0.0215 0.265 13.258 0.1084 0.00000 0.01030 0.00066 97.1 %The two routes agree on the salts where most of the chloride is: Kuzel's salt differs by 3 % at 0.4 %, and by a third at 0.05 %, where little of it forms. They differ on the C-S-H. The end member holds 0.00063 to 0.00066 mol of chloride where the surface holds 0.00018, and the paste binds 85 to 97 % of the chloride against 74 to 96 %. At the lowest dose the C-S-H holds half of the bound chloride in route B and a sixth in route A; at 0.4 % the AFm phases hold 94 to 98 % of it in both. Route B leaves the portlandite where the first stage put it.
Without portlandite
A CEM III/B at the lower bound of its clinker content, 20 %, with the same clinker, slag and reacted fractions, has no portlandite left, and its C-S-H has a Ca/Si of 1.52. Route A has nothing to rest on, and freeze_solid_solution says so. Route B applies unchanged.
CEM_III_B = 0.20 # clinker, the lower bound of CEM III/B in EN 197-1
p3 = paste(cs1; clinker_fraction = CEM_III_B)
eq3, cert3 = equilibrate_certified(p3.state; model, b = p3.b)
gel3 = solid_solution_totals(eq3, "CSHQ")
@printf("certified %s, pH %.3f, portlandite %.4f mol, Ca/Si of the gel %.3f\n", cert3.optimal,
pH(eq3, model), mol(eq3, "Portlandite"), gel3.elements[:Ca] / gel3.elements[:Si])
try
freeze_solid_solution(eq3, "CSHQ", cs2; buffer = "Portlandite")
catch e
println(first(split(sprint(showerror, e), ", so ")), ".")
end
route_c, certs_c = equilibrate_path(paste(csB; clinker_fraction = CEM_III_B).state,
[paste(csB; dose = d, clinker_fraction = CEM_III_B).b for d in DOSES]; model)
println("certified: ", count(c -> c.optimal, certs_c), " of ", length(certs_c))
report(route_c, csB, "CSHQ-Cl")certified true, pH 12.667, portlandite 0.0000 mol, Ca/Si of the gel 1.523
ArgumentError: "Portlandite" is absent from the first stage.
certified: 5 of 5
Cl % free Cl I pH CH Friedel Kuzel C-S-H bound
0.00 0.0000 0.061 12.667 0.0000 0.00000 0.00000 0.00000 0.0 %
0.05 0.0123 0.076 12.678 0.0000 0.00000 0.00025 0.00093 83.6 %
0.10 0.0153 0.087 12.734 0.0000 0.00000 0.00154 0.00099 89.9 %
0.20 0.0231 0.114 12.839 0.0000 0.00000 0.00409 0.00112 92.4 %
0.40 0.0455 0.177 12.992 0.0000 0.00000 0.00903 0.00142 92.5 %Where the chloride sits, dose by dose, along the three paths:
# Stacked bars with `Plots` alone: each bar is drawn at its cumulative height,
# the tallest first, so that the shorter ones in front leave each layer visible.
function stacked(states, cs, gel_species, title; legend = false)
r = [partition(eq, cs, d; gel_species) for (d, eq) in zip(DOSES, states)]
layers = ([x.friedel for x in r], [x.kuzel for x in r], [x.gel for x in r])
xs = collect(eachindex(DOSES))
p = plot(; xticks = (xs, string.(DOSES)), xlabel = "chloride added (% of binder)",
ylabel = "mmol of Cl per 100 g of binder", title, titlefontsize = 10, legend)
for (k, lab, c) in ((3, "C-S-H", :seagreen), (2, "Kuzel's salt", :darkorange), (1, "Friedel's salt", :steelblue))
bar!(p, xs, 1000 .* sum(layers[1:k]); bar_width = 0.6, label = lab, color = c)
end
return p
end
panels = [stacked(route_a, cs2, "XwOHCl-", "CEM III/A, route A (surface)"; legend = :topleft),
stacked(route_b, csB, "CSHQ-Cl", "CEM III/A, route B (end member)"),
stacked(route_c, csB, "CSHQ-Cl", "CEM III/B, route B")]
# One scale for the three, with room above the tallest bar.
plot(panels...; ylims = (0, 1.1 * maximum(ylims(p)[2] for p in panels)),
layout = (1, 3), size = (1100, 360), left_margin = 6Plots.mm, bottom_margin = 8Plots.mm)
Without portlandite the salt moves the pH further, from 12.67 to 12.99. The gel holds 0.0009 to 0.0014 mol of chloride, more than in the CEM III/A paste: four fifths of the bound chloride at the lowest dose, a seventh at the highest. No measurement on a slag cement checks these numbers; they are what the fitted end member predicts for a gel it was not fitted on.
See also
Chloride binding by C-S-H and Friedel's salt, the surface model on the paste of Guo et al. (2018), against PHREEQC (Parkhurst and Appelo, 2013).
data/chloride/README.md, the data, the fit and the rejected candidate.test/chloride_blended_reference.jlandtest/cshq_chloride.jl.