Skip to content

Commit fa34915

Browse files
andyclaude
andcommitted
The MO diagram's layout: levels, core folding, and symmetry connections
The drawing is the product, and this is the part of it with judgement in it -- what shares a level, what counts as core, what connects to what. Kept separate from anything that paints so it can be tested without a display; the panel will only render the result. Three decisions, each pinned by a test: LEVELS. Orbitals share a level when they share an irrep AND an energy. Both conditions matter, and the tests cover both directions: two irreps that happen to be degenerate stay two levels -- an accidental near-degeneracy is not a degenerate set -- and one irrep at two energies stays two. A level sits at the mean of its orbitals, so a set split by numerical noise is drawn where it belongs. It keeps its orbital indices, which is what makes a level clickable through to the viewer. CORE. Folded into a counted band rather than drawn, because thirty core levels squeeze the valence region into nothing. The cutoff is taken from the biggest gap in the lower half, judged against the TYPICAL gap rather than the total span: a span-based rule is scale-dependent and got the easy case wrong, since an evenly spaced spectrum of five levels has every gap at exactly a quarter of the span. A core sits four or more typical gaps from the valence; even spacing sits at one. That also gives the right answer for a semiempirical calculation, which has no core: nothing is hidden, rather than something arbitrary being hidden. The count is kept, so the diagram can say how many are folded away. An absence the reader cannot see is worse than a cluttered diagram. CONNECTIONS. Symmetry is the entire rule: same irrep may combine, different may not. A molecular orbital with no fragment partner of its irrep is non-bonding, and is drawn as such -- water's b2 lone pair is exactly that case, and is the test for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFMUNbw2UpRPmgsqviAowb
1 parent 7990306 commit fa34915

4 files changed

Lines changed: 407 additions & 0 deletions

File tree

‎include/tdat/MoDiagram.H‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
/**
2+
* @file
3+
*
4+
* The layout of a molecular orbital correlation diagram.
5+
*
6+
* Deliberately separate from anything that draws: what to put where is
7+
* decided here, in terms of levels, labels and connections, and the
8+
* panel only paints the result. That keeps the part with the judgement
9+
* in it -- which orbitals share a level, which count as core, what
10+
* connects to what -- testable without a display.
11+
*/
12+
13+
#ifndef MODIAGRAM_HH
14+
#define MODIAGRAM_HH
15+
16+
#include <string>
17+
#include <vector>
18+
using std::string;
19+
using std::vector;
20+
21+
/** One drawn level: a set of orbitals sharing an energy and a label. */
22+
struct MoLevel
23+
{
24+
double energy; // Hartree
25+
string label; // the irrep, as the code reports it
26+
double occupancy; // electrons in the whole level
27+
int degeneracy; // orbitals drawn as this one level
28+
vector<int> orbitals; // their indices, for clicking through
29+
30+
MoLevel() : energy(0.0), occupancy(0.0), degeneracy(0) {}
31+
};
32+
33+
34+
/** One column of the diagram. */
35+
struct MoColumn
36+
{
37+
string title;
38+
vector<MoLevel> levels;
39+
40+
/**
41+
* Orbitals folded away below the energy cutoff.
42+
*
43+
* Drawn as a single band rather than as levels: a molecule with
44+
* thirty core orbitals squeezes its valence region into nothing
45+
* otherwise. Kept as a count so the diagram can SAY how many are
46+
* hidden -- an absence the reader cannot see is worse than a
47+
* cluttered diagram.
48+
*/
49+
int hiddenCount;
50+
double hiddenMaxEnergy;
51+
52+
MoColumn() : hiddenCount(0), hiddenMaxEnergy(0.0) {}
53+
};
54+
55+
56+
/** A line between a fragment level and a molecular orbital. */
57+
struct MoConnection
58+
{
59+
int leftLevel; // index into the left column, or -1
60+
int centreLevel; // index into the centre column
61+
int rightLevel; // index into the right column, or -1
62+
};
63+
64+
65+
class MoDiagram
66+
{
67+
public:
68+
69+
MoDiagram();
70+
71+
/**
72+
* Group orbitals into drawn levels.
73+
*
74+
* Orbitals are one level when they share an irrep label AND lie
75+
* within tolerance in energy. The degeneracy that results is
76+
* reported, but a caller that knows the irrep's dimension should
77+
* prefer that: an accidental near-degeneracy is not a degenerate
78+
* set, and grouping by energy alone cannot tell them apart.
79+
*
80+
* @param energies one per orbital, Hartree, ascending
81+
* @param occupancies one per orbital
82+
* @param labels one per orbital; may be empty, in which case
83+
* grouping is by energy alone and every level
84+
* is labelled unknown
85+
* @param tolerance Hartree
86+
*/
87+
static bool group(const vector<double>& energies,
88+
const vector<double>& occupancies,
89+
const vector<string>& labels,
90+
double tolerance,
91+
vector<MoLevel>& levels);
92+
93+
/**
94+
* Fold levels below a cutoff into the column's hidden band.
95+
*
96+
* @param cutoff Hartree; levels below this are hidden
97+
*/
98+
static void hideBelow(MoColumn& column, double cutoff);
99+
100+
/**
101+
* A cutoff taken from the biggest gap in the lower half of the
102+
* spectrum, so it adapts to the molecule instead of assuming what
103+
* counts as core.
104+
*
105+
* Returns the lowest energy in the column when no useful gap is
106+
* found, which hides nothing.
107+
*/
108+
static double suggestCoreCutoff(const vector<MoLevel>& levels);
109+
110+
/**
111+
* Connect fragment levels to molecular orbitals by matching irrep.
112+
*
113+
* Symmetry is the whole rule: orbitals of different irreps cannot
114+
* mix, and orbitals of the same one must be allowed to. A centre
115+
* level with no fragment partner of its irrep is non-bonding and
116+
* gets a connection with -1 on that side.
117+
*/
118+
static void connect(const vector<MoLevel>& left,
119+
const vector<MoLevel>& centre,
120+
const vector<MoLevel>& right,
121+
vector<MoConnection>& connections);
122+
};
123+
124+
#endif

‎src/tdat/chemistry/MoDiagram.C‎

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#include <cmath>
2+
#include <cstddef>
3+
4+
#include "tdat/MoDiagram.H"
5+
6+
MoDiagram::MoDiagram()
7+
{
8+
}
9+
10+
11+
bool MoDiagram::group(const vector<double>& energies,
12+
const vector<double>& occupancies,
13+
const vector<string>& labels,
14+
double tolerance,
15+
vector<MoLevel>& levels)
16+
{
17+
levels.clear();
18+
19+
const size_t n = energies.size();
20+
if (n == 0) return false;
21+
if (!occupancies.empty() && occupancies.size() != n) return false;
22+
if (!labels.empty() && labels.size() != n) return false;
23+
24+
for (size_t i = 0; i < n; i++) {
25+
26+
const string label = labels.empty() ? string("?") : labels[i];
27+
28+
// Same level as the one before it? Both conditions matter: two
29+
// orbitals of DIFFERENT irreps can be accidentally degenerate and
30+
// are not one level, and two of the same irrep at different
31+
// energies are two levels.
32+
bool extend = false;
33+
if (!levels.empty()) {
34+
MoLevel& last = levels.back();
35+
extend = (last.label == label)
36+
&& (fabs(energies[i] - last.energy) <= tolerance);
37+
}
38+
39+
if (extend) {
40+
MoLevel& last = levels.back();
41+
// The level sits at the mean of its orbitals, so a set split by
42+
// numerical noise is drawn where it belongs rather than at
43+
// whichever of them happened to come first.
44+
last.energy = (last.energy*last.degeneracy + energies[i])
45+
/ (last.degeneracy + 1);
46+
last.degeneracy += 1;
47+
last.occupancy += occupancies.empty() ? 0.0 : occupancies[i];
48+
last.orbitals.push_back((int)i);
49+
} else {
50+
MoLevel level;
51+
level.energy = energies[i];
52+
level.label = label;
53+
level.occupancy = occupancies.empty() ? 0.0 : occupancies[i];
54+
level.degeneracy = 1;
55+
level.orbitals.push_back((int)i);
56+
levels.push_back(level);
57+
}
58+
}
59+
return true;
60+
}
61+
62+
63+
void MoDiagram::hideBelow(MoColumn& column, double cutoff)
64+
{
65+
vector<MoLevel> kept;
66+
column.hiddenCount = 0;
67+
column.hiddenMaxEnergy = 0.0;
68+
bool anyHidden = false;
69+
70+
for (size_t i = 0; i < column.levels.size(); i++) {
71+
if (column.levels[i].energy < cutoff) {
72+
column.hiddenCount += column.levels[i].degeneracy;
73+
if (!anyHidden || column.levels[i].energy > column.hiddenMaxEnergy) {
74+
column.hiddenMaxEnergy = column.levels[i].energy;
75+
}
76+
anyHidden = true;
77+
} else {
78+
kept.push_back(column.levels[i]);
79+
}
80+
}
81+
column.levels = kept;
82+
}
83+
84+
85+
double MoDiagram::suggestCoreCutoff(const vector<MoLevel>& levels)
86+
{
87+
if (levels.size() < 3) return levels.empty() ? -1.0e30
88+
: levels.front().energy;
89+
90+
// Every gap, and the typical one.
91+
vector<double> gaps;
92+
for (size_t i = 1; i < levels.size(); i++) {
93+
gaps.push_back(levels[i].energy - levels[i-1].energy);
94+
}
95+
vector<double> sorted(gaps);
96+
for (size_t i = 0; i < sorted.size(); i++) {
97+
for (size_t j = i+1; j < sorted.size(); j++) {
98+
if (sorted[j] < sorted[i]) {
99+
const double t = sorted[i]; sorted[i] = sorted[j]; sorted[j] = t;
100+
}
101+
}
102+
}
103+
const double median = sorted[sorted.size()/2];
104+
105+
// The biggest gap in the lower half, where a core/valence split
106+
// would be.
107+
const size_t limit = levels.size()/2;
108+
double bestGap = 0.0;
109+
size_t bestAt = 0;
110+
for (size_t i = 1; i <= limit && i < levels.size(); i++) {
111+
if (gaps[i-1] > bestGap) { bestGap = gaps[i-1]; bestAt = i; }
112+
}
113+
114+
// Judged against the TYPICAL gap, not against the total span. A
115+
// span-based rule is scale-dependent and gets the easy case wrong:
116+
// an evenly spaced spectrum of five levels has every gap at exactly
117+
// a quarter of the span, so any threshold near that either hides an
118+
// orbital for no reason or misses a real core. A core sits four or
119+
// more typical gaps away from the valence; even spacing sits at one.
120+
//
121+
// This also gives the right answer for a semiempirical calculation,
122+
// which has no core at all: its gaps are comparable, so nothing is
123+
// hidden rather than something arbitrary being hidden.
124+
if (median <= 0.0 || bestGap < 4.0*median) return levels.front().energy;
125+
126+
return 0.5*(levels[bestAt].energy + levels[bestAt-1].energy);
127+
}
128+
129+
130+
void MoDiagram::connect(const vector<MoLevel>& left,
131+
const vector<MoLevel>& centre,
132+
const vector<MoLevel>& right,
133+
vector<MoConnection>& connections)
134+
{
135+
connections.clear();
136+
137+
for (size_t c = 0; c < centre.size(); c++) {
138+
139+
MoConnection link;
140+
link.leftLevel = -1;
141+
link.centreLevel = (int)c;
142+
link.rightLevel = -1;
143+
144+
// Symmetry is the entire rule: only orbitals of the same irrep may
145+
// combine. A centre level with no partner on a side is
146+
// non-bonding from that side, which is a result worth drawing --
147+
// water's b2 lone pair is exactly this.
148+
for (size_t l = 0; l < left.size(); l++) {
149+
if (left[l].label == centre[c].label) { link.leftLevel = (int)l; break; }
150+
}
151+
for (size_t r = 0; r < right.size(); r++) {
152+
if (right[r].label == centre[c].label) { link.rightLevel = (int)r; break; }
153+
}
154+
connections.push_back(link);
155+
}
156+
}

‎tests/symmetry/run_tests.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,11 @@ def main():
529529
print("FAILED the orbit/reduction analysis")
530530
return 1
531531

532+
if standalone("testMoDiagram",
533+
["src/tdat/chemistry/MoDiagram.C"]) != 0:
534+
print("FAILED the diagram layout")
535+
return 1
536+
532537
print("PASSED")
533538
return 0
534539

0 commit comments

Comments
 (0)