From 5e9fdc7ca992e9d72335c4c709f2d6d1a00a34ae Mon Sep 17 00:00:00 2001 From: Christoph Ortner Date: Wed, 1 Jul 2026 05:00:19 -0700 Subject: [PATCH 1/4] Add isless, isequal, hash for ChemicalSpecies Impose a strict total order + identity on the raw fields (atomic_number, n_neutrons, name), enabling sort and correct use in Set/Dict/unique. These are deliberately stricter than the wildcard `==` matching relation. Co-Authored-By: Claude Opus 4.8 --- src/utils/chemspecies.jl | 32 ++++++++++++++++++++++++++++++-- test/species.jl | 30 ++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/utils/chemspecies.jl b/src/utils/chemspecies.jl index 0320d92..cddd07d 100644 --- a/src/utils/chemspecies.jl +++ b/src/utils/chemspecies.jl @@ -7,7 +7,7 @@ import PeriodicTable using Unitful -import Base: ==, convert, show, length +import Base: ==, convert, show, length, isless, isequal, hash export ChemicalSpecies @@ -74,6 +74,18 @@ ChemicalSpecies(:C12; atom_name=:MyC) == ChemicalSpecies(:C) ChemicalSpecies(:C; atom_name=:MyC) == ChemicalSpecies(:C12; atom_name=:MyC) ``` +Sorting and identity + +`ChemicalSpecies` supports `isless`, `isequal` and `hash`, so vectors can be +`sort`ed and species can be used as keys in `Set`/`Dict`. These impose a strict +total order on the raw fields `(atomic_number, n_neutrons, name)` and are +therefore *stricter* than the wildcard `==` above. For example `:C == :C13` is +`true` (matching), but `isequal(ChemicalSpecies(:C), ChemicalSpecies(:C13))` is +`false`. An unspecified isotope/name sorts before a specified one. +```julia +sort(ChemicalSpecies.([:O, :H, :C])) # -> [H, C, O] +``` + """ struct ChemicalSpecies atomic_number::Int16 # = Z = number of protons @@ -173,7 +185,23 @@ function ==(cs1::ChemicalSpecies, cs2::ChemicalSpecies) end end -# -------- fast access to the periodic table +# `isless`/`isequal`/`hash` impose a strict total order on the RAW fields +# (atomic_number, n_neutrons, name), which is what enables sorting and correct +# use in `Set`/`Dict`/`unique`. This is deliberately different from `==`, which +# is a wildcard *matching* relation (e.g. `:C == :C13` but `!isequal(:C, :C13)`). +# Consequently, an unspecified isotope (`n_neutrons == -1`) sorts before any +# specified isotope of the same element, and an unspecified name (`name == 0`) +# sorts first. The `name` tie-break is deterministic but not alphabetical, since +# `name` is a byte-packed `UInt32`. +_key(cs::ChemicalSpecies) = (cs.atomic_number, cs.n_neutrons, cs.name) + +isless(a::ChemicalSpecies, b::ChemicalSpecies) = isless(_key(a), _key(b)) + +isequal(a::ChemicalSpecies, b::ChemicalSpecies) = isequal(_key(a), _key(b)) + +hash(cs::ChemicalSpecies, h::UInt) = hash(_key(cs), hash(:ChemicalSpecies, h)) + +# -------- fast access to the periodic table const _sym2z = Dict{Symbol, UInt8}( Symbol(el.symbol) => el.number for el in PeriodicTable.elements diff --git a/test/species.jl b/test/species.jl index d97293b..757e1ab 100644 --- a/test/species.jl +++ b/test/species.jl @@ -85,6 +85,36 @@ tmp = ChemicalSpecies(:C12; atom_name=:MyC) @test element_symbol(ChemicalSpecies(:C)) == :C @test element_symbol(ChemicalSpecies(:C13)) == :C +@testset "ordering / identity" begin + # sort by atomic number + @test sort( ChemicalSpecies.([:O, :H, :C, :N]) ) == ChemicalSpecies.([:H, :C, :N, :O]) + @test issorted( ChemicalSpecies.([:H, :C, :N, :O]) ) + @test !issorted( ChemicalSpecies.([:O, :H]) ) + + # pairwise isless across elements + @test isless( ChemicalSpecies(:H), ChemicalSpecies(:C) ) + @test !isless( ChemicalSpecies(:C), ChemicalSpecies(:H) ) + + # isotope ordering: unspecified isotope sorts first, then by neutron count + @test isless( ChemicalSpecies(:C), ChemicalSpecies(:C12) ) + @test isless( ChemicalSpecies(:C12), ChemicalSpecies(:C13) ) + @test sort( ChemicalSpecies.([:C13, :C, :C12]) ) == ChemicalSpecies.([:C, :C12, :C13]) + + # isequal / hash are strict, unlike the wildcard == + @test ChemicalSpecies(:C) == ChemicalSpecies(:C13) # wildcard match + @test !isequal( ChemicalSpecies(:C), ChemicalSpecies(:C13) ) + @test !isequal( ChemicalSpecies(:C; atom_name=:MyC), ChemicalSpecies(:C) ) + @test isequal( ChemicalSpecies(:C12), ChemicalSpecies(:C12) ) + @test hash( ChemicalSpecies(:C12) ) == hash( ChemicalSpecies(:C12) ) + + # Set / Dict / unique behave correctly (rely on isequal + hash) + @test length( Set( ChemicalSpecies.([:C, :C, :C13, :C13]) ) ) == 2 + @test length( unique( ChemicalSpecies.([:C, :C, :O]) ) ) == 2 + d = Dict( ChemicalSpecies(:C12) => 1 ) + @test d[ ChemicalSpecies(:C12) ] == 1 + @test !haskey( d, ChemicalSpecies(:C13) ) +end + @testset "ChemicalSpecies in Atom and FastSystem" begin box = ([1, 0, 0]u"m", [0, 1, 0]u"m", [0, 0, 1]u"m") pbcs = (true, true, false) From db3b128092d3da19c0a2f1a8c24a9e9408978622 Mon Sep 17 00:00:00 2001 From: Christoph Ortner Date: Wed, 1 Jul 2026 05:22:20 -0700 Subject: [PATCH 2/4] bump v 0.5.3 --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 3dbd88e..fb84a4e 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "AtomsBase" uuid = "a963bdd2-2df7-4f54-a1ee-49d51e6be12a" authors = ["JuliaMolSim community"] -version = "0.5.2" +version = "0.5.3" [deps] LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" From d7287a5484ff358e31b787079011218109aee298 Mon Sep 17 00:00:00 2001 From: Christoph Ortner Date: Wed, 1 Jul 2026 06:34:51 -0700 Subject: [PATCH 3/4] Document == vs isequal difference for ChemicalSpecies Explain in both the docstring and the utilities docs page that == is a wildcard matching relation (non-transitive), while isequal/hash/isless provide strict identity/ordering used by sort, Set, Dict, unique. Co-Authored-By: Claude Opus 4.8 --- docs/src/utilities.md | 4 ++++ src/utils/chemspecies.jl | 24 +++++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/src/utilities.md b/docs/src/utilities.md index 592ef94..7eecf53 100644 --- a/docs/src/utilities.md +++ b/docs/src/utilities.md @@ -21,6 +21,10 @@ The function [`AtomsBase.species(sys, i)`](@ref) return the particle species of - [`ChemicalSpecies`](@ref) : a prototype implementation and recommended default for the species of an atom. +!!! note "`==` versus `isequal` for `ChemicalSpecies`" + `ChemicalSpecies` distinguishes two notions of comparison. `==` is a *matching* relation: an unspecified isotope or atom name acts as a wildcard, so e.g. `ChemicalSpecies(:C) == ChemicalSpecies(:C13)` is `true`. This is handy for queries (e.g. "is this atom carbon?"), but it is not a strict equality and is not even transitive (`:C == :C13` and `:C == :C12`, yet `:C12 != :C13`). + In contrast `isequal` (with the corresponding `hash`) is *strict* equality of all fields, and `isless` provides a strict total order. These are the well-behaved counterparts used by `sort`, `Set`, `Dict` and `unique`, so `isequal(ChemicalSpecies(:C), ChemicalSpecies(:C13))` is `false`. + ## Convenience functions AtomsBase provides a number of convenience utilities that should work for any system that implements the AtomsBase interface. If they not work as expected this is likely a bug and should be reported as an issue. diff --git a/src/utils/chemspecies.jl b/src/utils/chemspecies.jl index cddd07d..0368764 100644 --- a/src/utils/chemspecies.jl +++ b/src/utils/chemspecies.jl @@ -74,16 +74,22 @@ ChemicalSpecies(:C12; atom_name=:MyC) == ChemicalSpecies(:C) ChemicalSpecies(:C; atom_name=:MyC) == ChemicalSpecies(:C12; atom_name=:MyC) ``` -Sorting and identity - -`ChemicalSpecies` supports `isless`, `isequal` and `hash`, so vectors can be -`sort`ed and species can be used as keys in `Set`/`Dict`. These impose a strict -total order on the raw fields `(atomic_number, n_neutrons, name)` and are -therefore *stricter* than the wildcard `==` above. For example `:C == :C13` is -`true` (matching), but `isequal(ChemicalSpecies(:C), ChemicalSpecies(:C13))` is -`false`. An unspecified isotope/name sorts before a specified one. +`==` versus `isequal` + +`==` is a *matching* relation: an unspecified isotope (`n_neutrons < 0`) or +unspecified name (`name == 0`) acts as a wildcard that matches anything, as in +the examples above. This is convenient for queries (e.g. "is this atom carbon?") +but it is deliberately not a strict equality — it is not even transitive +(`:C == :C13` and `:C == :C12`, yet `:C12 != :C13`). + +In contrast `isequal` (together with `hash`) is *strict* equality of the raw +fields `(atomic_number, n_neutrons, name)`, and `isless` is a strict total order +on those same fields. These are what `sort`, `Set`, `Dict` and `unique` rely on, +so they are provided as the well-behaved counterparts to `==`. ```julia -sort(ChemicalSpecies.([:O, :H, :C])) # -> [H, C, O] +ChemicalSpecies(:C) == ChemicalSpecies(:C13) # true (wildcard match) +isequal(ChemicalSpecies(:C), ChemicalSpecies(:C13)) # false (strict identity) +sort(ChemicalSpecies.([:O, :H, :C])) # -> [H, C, O] ``` """ From d384e06dc0fb277cb0878a05e7524da33246ea72 Mon Sep 17 00:00:00 2001 From: Christoph Ortner Date: Wed, 1 Jul 2026 06:39:01 -0700 Subject: [PATCH 4/4] minor mod to isequal docs --- docs/src/utilities.md | 2 +- src/utils/chemspecies.jl | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/src/utilities.md b/docs/src/utilities.md index 7eecf53..ada4ddb 100644 --- a/docs/src/utilities.md +++ b/docs/src/utilities.md @@ -22,7 +22,7 @@ The function [`AtomsBase.species(sys, i)`](@ref) return the particle species of - [`ChemicalSpecies`](@ref) : a prototype implementation and recommended default for the species of an atom. !!! note "`==` versus `isequal` for `ChemicalSpecies`" - `ChemicalSpecies` distinguishes two notions of comparison. `==` is a *matching* relation: an unspecified isotope or atom name acts as a wildcard, so e.g. `ChemicalSpecies(:C) == ChemicalSpecies(:C13)` is `true`. This is handy for queries (e.g. "is this atom carbon?"), but it is not a strict equality and is not even transitive (`:C == :C13` and `:C == :C12`, yet `:C12 != :C13`). + `ChemicalSpecies` distinguishes two notions of comparison. `==` is a *matching* relation: an unspecified isotope or atom name acts as a wildcard, so e.g. `ChemicalSpecies(:C) == ChemicalSpecies(:C13)` is `true`. This is handy for queries (e.g. "is this atom carbon?"), but it is not a strict equality. In contrast `isequal` (with the corresponding `hash`) is *strict* equality of all fields, and `isless` provides a strict total order. These are the well-behaved counterparts used by `sort`, `Set`, `Dict` and `unique`, so `isequal(ChemicalSpecies(:C), ChemicalSpecies(:C13))` is `false`. ## Convenience functions diff --git a/src/utils/chemspecies.jl b/src/utils/chemspecies.jl index 0368764..86cd157 100644 --- a/src/utils/chemspecies.jl +++ b/src/utils/chemspecies.jl @@ -79,10 +79,9 @@ ChemicalSpecies(:C; atom_name=:MyC) == ChemicalSpecies(:C12; atom_name=:MyC) `==` is a *matching* relation: an unspecified isotope (`n_neutrons < 0`) or unspecified name (`name == 0`) acts as a wildcard that matches anything, as in the examples above. This is convenient for queries (e.g. "is this atom carbon?") -but it is deliberately not a strict equality — it is not even transitive -(`:C == :C13` and `:C == :C12`, yet `:C12 != :C13`). +but not a strict equality. -In contrast `isequal` (together with `hash`) is *strict* equality of the raw +In contrast `isequal` is *strict* equality of the raw fields `(atomic_number, n_neutrons, name)`, and `isless` is a strict total order on those same fields. These are what `sort`, `Set`, `Dict` and `unique` rely on, so they are provided as the well-behaved counterparts to `==`.