From 9565fad7b8536008d79998a1b846c6ee2cd8b94b Mon Sep 17 00:00:00 2001 From: zdroid Date: Sun, 9 Aug 2020 22:16:37 +0200 Subject: [PATCH] Add support for non-integer coefficients --- docs/src/examples.md | 56 +++++++++++++++++++++++++++++---------- docs/src/index.md | 4 +-- src/ChemEquations.jl | 4 +-- src/balance.jl | 63 +++++++++++++++++++++++++++++++++----------- src/chemequation.jl | 58 +++++++++++++++++++++++----------------- src/compound.jl | 45 +++++++++++++++---------------- test/balance.jl | 29 +++++++++++++++----- test/chemequation.jl | 5 ++++ 8 files changed, 177 insertions(+), 87 deletions(-) diff --git a/docs/src/examples.md b/docs/src/examples.md index b0f1172..edaa224 100644 --- a/docs/src/examples.md +++ b/docs/src/examples.md @@ -8,16 +8,17 @@ similar to `r` prefix for regex in Julia. ```julia-repl julia> equation = ce"Fe + Cl2 = FeCl3" -ce"Fe + Cl2 = FeCl3" +Fe + Cl2 = FeCl3 + julia> balance(equation) -ce"2 Fe + 3 Cl2 = 2 FeCl3" +2 Fe + 3 Cl2 = 2 FeCl3 ``` Parsing the input is insensitive to whitespace, so you don't have to be pedantic if you don't want to. ```julia-repl julia> balance(ce"KMnO4+ HCl = KCl+MnCl2 +H2O + Cl2") -ce"2 KMnO4 + 16 HCl = 2 KCl + 2 MnCl2 + 8 H2O + 5 Cl2" +2 KMnO4 + 16 HCl = 2 KCl + 2 MnCl2 + 8 H2O + 5 Cl2 ``` Parentheses (`()`), compounds written with `*` and electrical charges are all supported. @@ -26,10 +27,10 @@ Charge is also supposed to be in any of those forms. ```julia-repl julia> balance(ce"K4Fe(CN)6 + H2SO4 + H2O = K2SO4 + FeSO4 + (NH4)2SO4 + CO") -ce"K4FeC6N6 + 6 H2SO4 + 6 H2O = 2 K2SO4 + FeSO4 + 3 N2H8SO4 + 6 CO" +K4FeC6N6 + 6 H2SO4 + 6 H2O = 2 K2SO4 + FeSO4 + 3 N2H8SO4 + 6 CO julia> balance(ce"Cr2O7{-2} + H{+} + {-} = Cr{+3} + H2O") -ce"Cr2O7{-2} + 14 H{+} + 6 e = 2 Cr{+3} + 7 H2O" +Cr2O7{-2} + 14 H{+} + 6 e = 2 Cr{+3} + 7 H2O julia> balance(ce"CuSO4*5H2O = CuSO4 + H2O") CuSO9H10 = CuSO4 + 5 H2O @@ -38,17 +39,17 @@ CuSO9H10 = CuSO4 + 5 H2O Even the hardest exercises are in the reach: ```julia-repl julia> balance(ce"K4Fe(CN)6 + KMnO4 + H2SO4 = KHSO4 + Fe2(SO4)3 + MnSO4 + HNO3 + CO2 + H2O") -ce"10 K4FeC6N6 + 122 KMnO4 + 299 H2SO4 = 162 KHSO4 + 5 Fe2S3O12 + 122 MnSO4 + 60 HNO3 + 60 CO2 + 188 H2O" +10 K4FeC6N6 + 122 KMnO4 + 299 H2SO4 = 162 KHSO4 + 5 Fe2S3O12 + 122 MnSO4 + 60 HNO3 + 60 CO2 + 188 H2O ``` Writing equations with a different equal sign is also possible (see [`ChemEquation(::AbstractString)`](@ref) for reference): ```julia-repl julia> ce"N2+O2⇌2NO" -ce"N2 + O2 = 2 NO" +N2 + O2 = 2 NO -julia> ce" -ce"H2 + O2 = H2O" +julia> ce"H2 + O2 → H2O" +H2 + O2 = H2O ``` Are two chemical equations identical? Let's find out: @@ -67,10 +68,10 @@ The syntax is similar, just with `cc` prefix (**c**hemical **c**ompound) instead ```julia-repl julia> cc"CuSO4*5H2O" -cc"CuSO9H10" +CuSO9H10 julia> cc"H3O{+1}" -cc"H3O{+} +H3O{+} ``` As you could notice, input string is transformed so that every atom appears only once. @@ -86,7 +87,7 @@ All unicode characters that are letters (such as α and β) or symbols (such as That allows some exotic examples: ```julia-repl julia> ce"Σ{+1} + Θ{-1} = Θ2 + Σ2" -ce"Σ{+} + Θ{-} = Θ2 + Σ2" +Σ{+} + Θ{-} = Θ2 + Σ2 ``` This works because compounds are parsed by elements, where an element begins with an uppercase unicode letter and @@ -102,10 +103,37 @@ Examples of those are ⎔ (`\hexagon`), ⬡ (`varhexagon`), ⬢ (`\varhexagonbla Unicode input allows writing some equations very nicely: ```julia-repl julia> ce"⏣H + Cl2 = ⏣Cl + HCl" -ce"⏣H + Cl2 = ⏣Cl + HCl" +⏣H + Cl2 = ⏣Cl + HCl julia> ce"C + α = O + γ" # a reaction from triple-α process -ce"C + α = O + γ" +C + α = O + γ +``` + +## Non-integer coefficients + +Sometimes coefficients in a chemical equation are written as fractions or decimals. + +To initialize such equation, you need to specify the appropriate Julia type for the coefficients. +`Rational` or `Rational{Int}` is appropriate for exact fractions, while `Float64` is appropriate for decimals. +```julia-repl +julia> ChemEquation{Rational}("1//2 H2 + 1//2 Cl2 → HCl") +1//2 H2 = H + +julia> ChemEquation{Float64}("0.5 H2 + 0.5 Cl2 = HCl") +0.5 H2 + 0.5 Cl2 = HCl +``` +Previous two examples are equivalent (test it with `==`!), thanks to the way that numbers are stored in Julia. + +You can also initialize the equation normally: +```julia-repl +julia> eq = ce"H2 + Cl2 → HCl" +H2 + Cl2 = HCl +``` + +and then choose to balance it with rational fractions as coefficients: +```julia-repl +julia> balance(eq, fractions=true) +1//2 H2 + 1//2 Cl2 = HCl ``` ## Advanced usage diff --git a/docs/src/index.md b/docs/src/index.md index d8e1626..f7bdab0 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -13,13 +13,13 @@ should be as simple as this: julia> using ChemEquations julia> equation = ce"CH4 + O2 = CO2 + H2O" -ce"CH4 + O2 = CO2 + H2O" +CH4 + O2 = CO2 + H2O ``` and balancing it should be even easier: ```julia-repl julia> balance(equation) -ce"CH4 + 2 O2 = CO2 + 2 H2O" +CH4 + 2 O2 = CO2 + 2 H2O ``` ## Installation diff --git a/src/ChemEquations.jl b/src/ChemEquations.jl index 6f58896..0149f11 100644 --- a/src/ChemEquations.jl +++ b/src/ChemEquations.jl @@ -5,7 +5,7 @@ Write and balance chemical equations elegantly and efficiently. """ module ChemEquations -using LinearAlgebraX: I, nullspacex +using LinearAlgebraX: I, nullspacex, IntegerX using DocStringExtensions @template (CONSTANTS, MACROS) = """ @@ -27,7 +27,7 @@ using DocStringExtensions export Compound, ChemEquation, @ce_str, @cc_str, ==, string, show, compounds, elements, hascharge, - equationmatrix, balance + equationmatrix, balancematrix, balance include("compound.jl") include("chemequation.jl") diff --git a/src/balance.jl b/src/balance.jl index ab12cf1..2c4737d 100644 --- a/src/balance.jl +++ b/src/balance.jl @@ -32,6 +32,11 @@ function equationmatrix(equation::ChemEquation{T}) where T return mat end +"Wrapper function for [`balancematrix`](@ref)." +function _balancematrix(equation) + -nullspacex(equationmatrix(equation)) +end + """ Balances an equation matrix using the *nullspace method*. Returns an array in which each column represents a solution. @@ -39,13 +44,24 @@ Returns an array in which each column represents a solution. # References - [Thorne (2009)](https://arxiv.org/ftp/arxiv/papers/1110/1110.4321.pdf) """ -function balance(mat::AbstractMatrix) - return nullspacex(mat) +balancematrix(equation::ChemEquation) = _balancematrix(equation) + +""" +Same as [`balancematrix(::ChemEquation)`](@ref), but with special format options. +By default, the solutions of integer matrices are displayed as integers. +If `fractions` is true, they will be displayed as rational fractions instead. +""" +function balancematrix(equation::ChemEquation{T}; fractions=false) where T<:IntegerX + mat = _balancematrix(equation) + if !fractions + mat ./= gcd(mat) + T.(mat) + end + return mat end """ Balances the coefficients of a chemical equation. -The minimal integer solution is always displayed. If the equation cannot be balanced, an error is thrown. @@ -55,22 +71,40 @@ If the equation cannot be balanced, an error is thrown. # Examples ```jldoctest julia> balance(ce"Fe + Cl2 = FeCl3") -ce"2 Fe + 3 Cl2 = 2 FeCl3" +2 Fe + 3 Cl2 = 2 FeCl3 + +julia> balance(ChemEquation{Rational}("H2 + Cl2 = HCl")) +1//2 H2 + 1//2 Cl2 = HCl +""" +balance(equation::ChemEquation) = _balance(equation) + +""" +Balances the coefficients of a chemical equation with integer coefficients. +The minimal integer solution is displayed by default. +If `fractions` is true, they solution will be displayed as rational fractions instead. + +# Examples +```jldoctest +julia> balance(ce"Fe + Cl2 = FeCl3", fractions=true) +Fe + 3//2 Cl2 = FeCl3 julia> balance(ce"Cr2O7{-2} + H{+} + {-} = Cr{+3} + H2O") -ce"Cr2O7{-2} + 14 H{+} + 6 e = 2 Cr{+3} + 7 H2O" +Cr2O7{-2} + 14 H{+} + 6 e = 2 Cr{+3} + 7 H2O ``` """ -function balance(equation::ChemEquation; fractions=false) - eq = deepcopy(equation) - mat = balance(equationmatrix(eq)) - num = size(mat)[2] +balance(equation::ChemEquation{<:IntegerX}; fractions=false) = _balance(equation, fractions) +"Wrapper function for [`balance`](@ref)." +function _balance(equation::ChemEquation, fractions=false) + if fractions + eq = ChemEquation{Rational}(equation.tuples) + mat = balancematrix(equation, fractions=true) + else + eq = ChemEquation(equation.tuples) + mat = balancematrix(equation) + end + num = size(mat)[2] if num == 1 - if !fractions - mat ./= -gcd(mat) - mat = Int.(mat) - end for (i, k) ∈ enumerate(mat) eq.tuples[i] = (eq.tuples[i][1], k) end @@ -79,6 +113,5 @@ function balance(equation::ChemEquation; fractions=false) else error("Chemical equation $equation cannot be balanced") end - return eq -end +end \ No newline at end of file diff --git a/src/chemequation.jl b/src/chemequation.jl index f6f6f9b..ecffe41 100644 --- a/src/chemequation.jl +++ b/src/chemequation.jl @@ -1,5 +1,5 @@ "Type stored in `ChemEquation.tuples`." -const CompoundTuple{T} = Tuple{Compound{T}, T} +const CompoundTuple{T} = Tuple{Compound, T} fwd_arrows = ['>', '→', '↣', '↦', '⇾', '⟶', '⟼', '⥟', '⥟', '⇀', '⇁', '⇒', '⟾'] bwd_arrows = ['<', '←', '↢', '↤', '⇽', '⟵', '⟻', '⥚', '⥞', '↼', '↽', '⇐', '⟽'] @@ -15,13 +15,25 @@ const EQUALCHARS = vcat(fwd_arrows, bwd_arrows, double_arrows, pure_rate_arrows, "Regex to split a chemical equation into compounds." const PLUSREGEX = r"(? ChemEquation{Rational}("1//2 H2 → H") +1//2 H2 = H + +julia> ChemEquation{Float64}("0.5 H2 + 0.5 Cl2 = HCl") +0.5 H2 + 0.5 Cl2 = HCl +``` +""" +ChemEquation{T}(str::AbstractString) where T<:Real = ChemEquation(_compoundtuples(str, T)) """ Constructs a chemical equation from the given string. @@ -33,20 +45,19 @@ while `+` separates the equation into compounds. # Examples ```jldoctest julia> ChemEquation("N2+O2⇌2NO") -ce"N2 + O2 = 2 NO" +N2 + O2 = 2 NO julia> ChemEquation("CH3COOH + Na → H2 + CH3COONa") -ce"C2H4O2 + Na = H2 + C2H3O2Na" +C2H4O2 + Na = H2 + C2H3O2Na julia> ChemEquation("⏣H + Cl2 = ⏣Cl + HCl") -ce"⏣H + Cl2 = ⏣Cl + HCl" +⏣H + Cl2 = ⏣Cl + HCl ``` """ -ChemEquation{T}(str::AbstractString) where T<:Number = ChemEquation(compoundtuples(str, T)) ChemEquation(str::AbstractString) = ChemEquation{Int}(str) "Extracts compound tuples from equation's string." -function compoundtuples(str::AbstractString, T::Type) +function _compoundtuples(str::AbstractString, T::Type) strs = replace(str, ' ' => "") |> x -> split(x, EQUALCHARS) |> x -> split.(x, PLUSREGEX) @@ -56,9 +67,10 @@ function compoundtuples(str::AbstractString, T::Type) for (i, compound) ∈ enumerate(strs) k = 1 - if isdigit(compound[1]) - k, compound = match(r"(^\d+)(.+)", compound).captures - k = parse(Int, k) + if isdigit(compound[1]) # begins with a digit + charindex = findfirst(r"\(?(\p{L}|\p{S})", compound)[1] + k, compound = compound[1:charindex-1], compound[charindex:end] + k = Meta.parse(k) |> eval end if i > splitindex k *= -1 @@ -75,7 +87,7 @@ Constructs a chemical equation with `ce"str"` syntax, instead of `ChemEquation(s # Examples ```jldoctest julia> ce"H2 + O2 → H2O" -ce"H2 + O2 = H2O" +H2 + O2 = H2O ``` """ macro ce_str(str) ChemEquation(str) end @@ -112,16 +124,14 @@ function Base.string(equation::ChemEquation) left = String[] right = String[] for (compound, k) ∈ equation.tuples - str = string(compound) + str = "" + if k ∉ (-1, 1) + str *= string(abs(k)) * " " + end + str *= string(compound) if k > 0 - if k ≠ 1 - str = "$k " * str - end push!(left, str) - elseif k < 0 - if k ≠ -1 - str = "$(-k) " * str - end + else push!(right, str) end end @@ -132,7 +142,7 @@ end "Displays the chemical equation using [`Base.string(::Compound)`](@ref)." function Base.show(io::IO, equation::ChemEquation) - print(io, "ce", '"', string(equation), '"') + print(io, string(equation)) end "Returns chemical equation's compounds in a list." diff --git a/src/compound.jl b/src/compound.jl index 2a0d2cb..4d66300 100644 --- a/src/compound.jl +++ b/src/compound.jl @@ -1,5 +1,5 @@ "Type stored in `Compound.tuples`." -const ElementTuple{T} = Tuple{String, T} +const ElementTuple = Tuple{String, Int} "Regex to match `{...}` charge string." const CHARGEREGEX = r"{(.*)}" @@ -10,11 +10,10 @@ Stores chemical compound's elements and charge in a structured way. !!! info Electron is stored as `"e"`. """ -struct Compound{T<:Number} - tuples::Vector{ElementTuple{T}} - charge::T +struct Compound + tuples::Vector{ElementTuple} + charge::Int end -Compound(tuples::Vector{ElementTuple{T}}, charge::T) where T = Compound{T}(tuples, charge) """ Constructs a compound from `str`. @@ -37,35 +36,34 @@ It is automatically deduced for electron (`"e"`). # Examples ```jldoctest julia> Compound("H2O") -cc"H2O" +H2O julia> Compound("H3O{+}") -cc"H3O{+}" +H3O{+} julia> Compound("(CH3COO)2Mg") -cc"C4H6O4Mg" +C4H6O4Mg julia> Compound("CuSO4 * 5H2O") -cc"CuSO9H10" +CuSO9H10 julia> Compound("⬡Cl") -cc"⬡Cl" +⬡Cl ``` """ -function Compound{T}(str::AbstractString) where T +function Compound(str::AbstractString) str = replace(str, [' ', '_'] => "") - Compound(elementtuples(str, T), charge(str, T)) + Compound(_elementtuples(str), _charge(str)) end -Compound(str::AbstractString) = Compound{Int}(str) "Extracts element tuples from compound's string." -function elementtuples(str::AbstractString, T::Type) +function _elementtuples(str::AbstractString) str = replace(str, CHARGEREGEX => "") if str ∈ ("", "e") return [("e", 1)] end - tuples = ElementTuple{T}[] + tuples = ElementTuple[] # Add 1 to elements and parens without a coefficient str = replace(str, r"(?\p{L}|\p{S}|\))(?=(\p{Lu}|\(|\)|\*|$))" => s"\g1") @@ -84,8 +82,7 @@ function elementtuples(str::AbstractString, T::Type) for (i, substr) ∈ enumerate(str) if isdigit(substr[1]) k, substr = match(r"(^\d+)(.+)", substr).captures - k = parse(Int, k) - substr = replace(substr, isdigit => x -> string(k * parse(Int, x))) + substr = replace(substr, isdigit => x -> string(parse(Int, k) * parse(Int, x))) str[i] = substr end end @@ -107,14 +104,14 @@ function elementtuples(str::AbstractString, T::Type) end "Extracts charge from compound's string into a number of specified type." -function charge(str::AbstractString, T::Type) +function _charge(str::AbstractString) if str == "e" - return T(-1) + return -1 end strmatch = match(CHARGEREGEX, str) if isnothing(strmatch) - return T(0) + return 0 else str = strmatch.captures[1] if str ∈ ("-", "+") @@ -122,7 +119,7 @@ function charge(str::AbstractString, T::Type) elseif str[end] ∈ ('-', '+') str = str[end] * str[1:end-1] end - return Meta.parse(str) |> eval |> T + return parse(Int, str) end end @@ -132,7 +129,7 @@ Constructs a compound with `cc"str"` syntax, instead of `Compound(str)`. # Examples ```jldoctest julia> cc"H3O{+1}" -cc"H3O{+}" +H3O{+} ``` """ macro cc_str(str) Compound(str) end @@ -172,7 +169,7 @@ function Base.string(compound::Compound) str = "" for (element, k) ∈ compound.tuples str *= element - if k > 1 + if k ≠ 1 str *= string(k) end end @@ -193,7 +190,7 @@ end "Displays the compound using [`Base.string(::Compound)`](@ref)." function Base.show(io::IO, compound::Compound) - print(io, "cc", '"', string(compound), '"') + print(io, string(compound)) end """ diff --git a/test/balance.jl b/test/balance.jl index 5b10f94..18d35ec 100644 --- a/test/balance.jl +++ b/test/balance.jl @@ -1,5 +1,7 @@ combustion = ce"C2H4 + O2 = CO2 + 2 H2O" redox = ce"H{+} + Cr2O7{2-} + C2H5OH = Cr{3+} + CO2 + H2O" +cerational = ChemEquation{Rational}("1//2 H2 + O2 → H2O") +cefloat = ChemEquation{Float64}("0.33 N3 + 0.5 O2 = NO") @testset "equationmatrix" begin @test equationmatrix(combustion) == [ @@ -14,17 +16,28 @@ redox = ce"H{+} + Cr2O7{2-} + C2H5OH = Cr{3+} + CO2 + H2O" 0 0 2 0 1 0 1 -2 0 3 0 0 ] + @test equationmatrix(cerational) == [ + 2//1 0//1 2//1 + 0//1 2//1 1//1 + ] + @test equationmatrix(cefloat) == [ + 3.0 0.0 1.0 + 0.0 2.0 1.0 + ] @test equationmatrix(ce"151 H2 + 32 O2 = 7 H2O") == equationmatrix(ce"5 H2 + O2 = 5 H2O") end -@testset "balance" begin +@testset "balancematrix" begin # [:,:] required to reshape Vector to Nx1 Array - @test balance(equationmatrix(combustion)) == [-1//2; -3//2; 1//1; 1//1][:,:] - @test balance(equationmatrix(redox)) == [-16//11; -2//11; -1//11; 4//11; 2//11; 1//1][:,:] - @test balance(equationmatrix(ce"H2 + O2 = H2O")) == - balance(equationmatrix(ce"2 H2 + O2 = 2 H2O")) + @test balancematrix(combustion) == [1//1; 3//1; -2//1; -2//1][:,:] + @test balancematrix(redox, fractions=true) == [16//11; 2//11; 1//11; -4//11; -2//11; -1//1][:,:] + @test balancematrix(cerational) == [1//1; 1//2; -1//1][:,:] + @test balancematrix(cefloat) == [0.3333333333333333; 0.5; -1.0][:,:] + @test balancematrix(ce"H2 + O2 = H2O") == balancematrix(ce"2 H2 + O2 = 2 H2O") +end +@testset "balance" begin # Original equation should be unchanged balanced_combustion = balance(combustion) @test combustion == combustion @@ -48,12 +61,16 @@ end "PhCH3 + KMnO4 + H2SO4 = PhCOOH + K2SO4 + MnSO4 + H2O" => "5 PhCH3 + 6 KMnO4 + 9 H2SO4 = 5 PhCO2H + 3 K2SO4 + 6 MnSO4 + 14 H2O", "CuSO4*5H2O = CuSO4 + H2O" => - "CuSO4*5H2O = CuSO4 + 5 H2O" + "CuSO4*5H2O = CuSO4 + 5 H2O", ] for equation ∈ equations @test balance(ChemEquation(equation[1])) == ChemEquation(equation[2]) end + @test balance(cerational) == ChemEquation{Rational}("H2 + 1//2 O2 = H2O") + @test balance(cefloat) == ChemEquation{Float64}("0.3333333333333333 N3 + 0.5 O2 = NO") + @test balance(combustion, fractions=true) == ChemEquation{Rational}("1//2 C2H4 + 3//2 O2 = CO2 + H2O") + @test_throws ErrorException balance(ce"H2 + O = H + O") @test_throws ErrorException balance(ce"H2 + CO = H2O") end diff --git a/test/chemequation.jl b/test/chemequation.jl index 6660a73..04ea8b3 100644 --- a/test/chemequation.jl +++ b/test/chemequation.jl @@ -9,6 +9,11 @@ redox = ce"Cr2O7{-2} + H{+1} + {-} = Cr{3+} + H2O" [(cc"Na{+1}", 1), (cc"Cl{-1}", 1), (cc"ClNa", -1)] @test ChemEquation("N2+O2⇌2NO").tuples == [(cc"N2", 1), (cc"O2", 1), (cc"NO", -2)] + + testtuple = [(cc"H2", 0.5), (cc"Cl2", 0.5), (cc"HCl", -1.0)] + @test ChemEquation(testtuple) == ChemEquation{Float64}(testtuple) + @test ChemEquation{Rational}("1//2 H2 + 1//2 Cl2 → HCl").tuples == testtuple + @test ChemEquation{Float64}("0.5 H2 + 0.5 Cl2 → HCl").tuples == testtuple end @testset "@ce_str" begin