molbuilder 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. molbuilder/__init__.py +8 -0
  2. molbuilder/__main__.py +6 -0
  3. molbuilder/atomic/__init__.py +4 -0
  4. molbuilder/atomic/bohr.py +235 -0
  5. molbuilder/atomic/quantum_atom.py +334 -0
  6. molbuilder/atomic/quantum_numbers.py +196 -0
  7. molbuilder/atomic/wavefunctions.py +297 -0
  8. molbuilder/bonding/__init__.py +4 -0
  9. molbuilder/bonding/covalent.py +442 -0
  10. molbuilder/bonding/lewis.py +347 -0
  11. molbuilder/bonding/vsepr.py +433 -0
  12. molbuilder/cli/__init__.py +1 -0
  13. molbuilder/cli/demos.py +516 -0
  14. molbuilder/cli/menu.py +127 -0
  15. molbuilder/cli/wizard.py +831 -0
  16. molbuilder/core/__init__.py +6 -0
  17. molbuilder/core/bond_data.py +170 -0
  18. molbuilder/core/constants.py +51 -0
  19. molbuilder/core/element_properties.py +183 -0
  20. molbuilder/core/elements.py +181 -0
  21. molbuilder/core/geometry.py +232 -0
  22. molbuilder/gui/__init__.py +2 -0
  23. molbuilder/gui/app.py +286 -0
  24. molbuilder/gui/canvas3d.py +115 -0
  25. molbuilder/gui/dialogs.py +117 -0
  26. molbuilder/gui/event_handler.py +118 -0
  27. molbuilder/gui/sidebar.py +105 -0
  28. molbuilder/gui/toolbar.py +71 -0
  29. molbuilder/io/__init__.py +1 -0
  30. molbuilder/io/json_io.py +146 -0
  31. molbuilder/io/mol_sdf.py +169 -0
  32. molbuilder/io/pdb.py +184 -0
  33. molbuilder/io/smiles_io.py +47 -0
  34. molbuilder/io/xyz.py +103 -0
  35. molbuilder/molecule/__init__.py +2 -0
  36. molbuilder/molecule/amino_acids.py +919 -0
  37. molbuilder/molecule/builders.py +257 -0
  38. molbuilder/molecule/conformations.py +70 -0
  39. molbuilder/molecule/functional_groups.py +484 -0
  40. molbuilder/molecule/graph.py +712 -0
  41. molbuilder/molecule/peptides.py +13 -0
  42. molbuilder/molecule/stereochemistry.py +6 -0
  43. molbuilder/process/__init__.py +3 -0
  44. molbuilder/process/conditions.py +260 -0
  45. molbuilder/process/costing.py +316 -0
  46. molbuilder/process/purification.py +285 -0
  47. molbuilder/process/reactor.py +297 -0
  48. molbuilder/process/safety.py +476 -0
  49. molbuilder/process/scale_up.py +427 -0
  50. molbuilder/process/solvent_systems.py +204 -0
  51. molbuilder/reactions/__init__.py +3 -0
  52. molbuilder/reactions/functional_group_detect.py +728 -0
  53. molbuilder/reactions/knowledge_base.py +1716 -0
  54. molbuilder/reactions/reaction_types.py +102 -0
  55. molbuilder/reactions/reagent_data.py +1248 -0
  56. molbuilder/reactions/retrosynthesis.py +1430 -0
  57. molbuilder/reactions/synthesis_route.py +377 -0
  58. molbuilder/reports/__init__.py +158 -0
  59. molbuilder/reports/cost_report.py +206 -0
  60. molbuilder/reports/molecule_report.py +279 -0
  61. molbuilder/reports/safety_report.py +296 -0
  62. molbuilder/reports/synthesis_report.py +283 -0
  63. molbuilder/reports/text_formatter.py +170 -0
  64. molbuilder/smiles/__init__.py +4 -0
  65. molbuilder/smiles/parser.py +487 -0
  66. molbuilder/smiles/tokenizer.py +291 -0
  67. molbuilder/smiles/writer.py +375 -0
  68. molbuilder/visualization/__init__.py +1 -0
  69. molbuilder/visualization/bohr_viz.py +166 -0
  70. molbuilder/visualization/molecule_viz.py +368 -0
  71. molbuilder/visualization/quantum_viz.py +434 -0
  72. molbuilder/visualization/theme.py +12 -0
  73. molbuilder-1.0.0.dist-info/METADATA +360 -0
  74. molbuilder-1.0.0.dist-info/RECORD +78 -0
  75. molbuilder-1.0.0.dist-info/WHEEL +5 -0
  76. molbuilder-1.0.0.dist-info/entry_points.txt +2 -0
  77. molbuilder-1.0.0.dist-info/licenses/LICENSE +21 -0
  78. molbuilder-1.0.0.dist-info/top_level.txt +1 -0
molbuilder/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ molbuilder -- Professional-grade molecular engineering tool.
3
+
4
+ Build, analyze, and plan synthesis of molecules from atoms up through
5
+ industrial-scale manufacturing processes.
6
+ """
7
+
8
+ __version__ = "1.0.0"
molbuilder/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m molbuilder`."""
2
+
3
+ from molbuilder.cli.menu import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,4 @@
1
+ """Atomic models: Bohr, quantum numbers, wavefunctions."""
2
+ from molbuilder.atomic.bohr import BohrAtom
3
+ from molbuilder.atomic.quantum_atom import QuantumAtom
4
+ from molbuilder.atomic.quantum_numbers import QuantumState
@@ -0,0 +1,235 @@
1
+ """
2
+ Bohr Model of the Atom
3
+
4
+ Represents atoms using the Bohr model with quantized energy levels,
5
+ electron shell configurations, and orbital mechanics. Provides both
6
+ computational physics and animated visualization.
7
+
8
+ Physics reference:
9
+ - Orbital radius: r_n = n^2 * a_0 / Z
10
+ - Energy level: E_n = -13.6 eV * Z^2 / n^2
11
+ - Photon energy: delta_E = E_final - E_initial
12
+ - Wavelength: lambda = h*c / |delta_E|
13
+ - Orbital velocity: v_n = (Z * e^2) / (n * hbar)
14
+ """
15
+
16
+ import math
17
+ import warnings
18
+ from molbuilder.core.constants import (
19
+ BOHR_RADIUS_M,
20
+ BOHR_RADIUS_PM,
21
+ PLANCK_CONSTANT,
22
+ HBAR,
23
+ SPEED_OF_LIGHT,
24
+ ELECTRON_CHARGE,
25
+ ELECTRON_MASS,
26
+ COULOMB_CONSTANT,
27
+ EV_TO_JOULES,
28
+ RYDBERG_ENERGY_EV,
29
+ MAX_ELECTRONS_PER_SHELL,
30
+ VACUUM_PERMITTIVITY,
31
+ )
32
+ from molbuilder.core.elements import ELEMENTS, SYMBOL_TO_Z
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Core Bohr model class
37
+ # ---------------------------------------------------------------------------
38
+ class BohrAtom:
39
+ """Represents an atom using the Bohr model.
40
+
41
+ The Bohr model is only physically valid for hydrogen-like (one-electron)
42
+ atoms and ions (H, He+, Li2+, etc.). For multi-electron atoms, results
43
+ are qualitative approximations. Use QuantumAtom from
44
+ molbuilder.atomic.quantum_atom for multi-electron systems.
45
+
46
+ Parameters
47
+ ----------
48
+ atomic_number : int
49
+ Number of protons (Z). Determines the element.
50
+ mass_number : int or None
51
+ Protons + neutrons. Defaults to rounded standard atomic weight.
52
+ charge : int
53
+ Net ionic charge (0 = neutral, +1 = lost one electron, etc.).
54
+ """
55
+
56
+ def __init__(self, atomic_number: int, mass_number: int = None, charge: int = 0):
57
+ if atomic_number < 1 or atomic_number > 118:
58
+ raise ValueError(f"Atomic number must be 1-118, got {atomic_number}")
59
+
60
+ self.atomic_number = atomic_number # Z
61
+ self.protons = atomic_number
62
+ symbol, name, weight = ELEMENTS[atomic_number]
63
+ self.symbol = symbol
64
+ self.name = name
65
+ self.standard_atomic_weight = weight
66
+
67
+ if mass_number is None:
68
+ self.mass_number = round(weight)
69
+ else:
70
+ self.mass_number = mass_number
71
+
72
+ self.neutrons = self.mass_number - self.protons
73
+ self.charge = charge
74
+ self.num_electrons = self.protons - charge
75
+
76
+ if self.num_electrons < 0:
77
+ raise ValueError("Charge exceeds number of protons (no electrons left)")
78
+
79
+ if self.num_electrons > 1:
80
+ warnings.warn(
81
+ f"BohrAtom(Z={atomic_number}): Bohr model is only exact for hydrogen-like "
82
+ f"(one-electron) systems. Results for Z={atomic_number} are qualitative. "
83
+ f"Consider using QuantumAtom for multi-electron atoms.",
84
+ stacklevel=2,
85
+ )
86
+
87
+ self.shell_config = self._compute_shell_config()
88
+ self.num_shells = len(self.shell_config)
89
+
90
+ # ----- electron configuration -----
91
+
92
+ def _compute_shell_config(self) -> list[int]:
93
+ """Fill electron shells following the 2n^2 rule."""
94
+ remaining = self.num_electrons
95
+ shells = []
96
+ n = 1
97
+ while remaining > 0 and n <= 7:
98
+ capacity = 2 * n**2
99
+ electrons_in_shell = min(remaining, capacity)
100
+ shells.append(electrons_in_shell)
101
+ remaining -= electrons_in_shell
102
+ n += 1
103
+ return shells
104
+
105
+ # ----- Bohr radius & energy -----
106
+
107
+ def orbital_radius(self, n: int) -> float:
108
+ """Radius of the n-th Bohr orbit in metres.
109
+
110
+ r_n = n^2 * a_0 / Z
111
+ """
112
+ if n < 1:
113
+ raise ValueError("Principal quantum number n must be >= 1")
114
+ return (n**2 * BOHR_RADIUS_M) / self.atomic_number
115
+
116
+ def orbital_radius_pm(self, n: int) -> float:
117
+ """Radius of the n-th Bohr orbit in picometres."""
118
+ return self.orbital_radius(n) * 1e12
119
+
120
+ def energy_level(self, n: int) -> float:
121
+ """Energy of the n-th level in eV.
122
+
123
+ E_n = -13.6 eV * Z^2 / n^2
124
+ """
125
+ if n < 1:
126
+ raise ValueError("Principal quantum number n must be >= 1")
127
+ return -RYDBERG_ENERGY_EV * (self.atomic_number**2) / (n**2)
128
+
129
+ def energy_level_joules(self, n: int) -> float:
130
+ """Energy of the n-th level in joules."""
131
+ return self.energy_level(n) * EV_TO_JOULES
132
+
133
+ def orbital_velocity(self, n: int) -> float:
134
+ """Velocity of the electron in the n-th orbit in m/s.
135
+
136
+ v_n = Z * e^2 / (n * 4*pi*eps_0 * hbar)
137
+ = Z * alpha * c / n
138
+ where alpha ~ 1/137 is the fine-structure constant.
139
+ """
140
+ alpha = ELECTRON_CHARGE**2 / (4 * math.pi * VACUUM_PERMITTIVITY * HBAR * SPEED_OF_LIGHT)
141
+ return self.atomic_number * alpha * SPEED_OF_LIGHT / n
142
+
143
+ def orbital_period(self, n: int) -> float:
144
+ """Orbital period of the electron in shell n, in seconds."""
145
+ r = self.orbital_radius(n)
146
+ v = self.orbital_velocity(n)
147
+ return 2 * math.pi * r / v
148
+
149
+ # ----- transitions -----
150
+
151
+ def transition_energy(self, n_initial: int, n_final: int) -> float:
152
+ """Energy of a photon emitted (positive) or absorbed (negative)
153
+ during a transition, in eV."""
154
+ return self.energy_level(n_initial) - self.energy_level(n_final)
155
+
156
+ def transition_wavelength(self, n_initial: int, n_final: int) -> float:
157
+ """Wavelength in metres of a photon from a transition between levels."""
158
+ delta_e = abs(self.transition_energy(n_initial, n_final)) * EV_TO_JOULES
159
+ if delta_e == 0:
160
+ return float('inf')
161
+ return PLANCK_CONSTANT * SPEED_OF_LIGHT / delta_e
162
+
163
+ def transition_wavelength_nm(self, n_initial: int, n_final: int) -> float:
164
+ """Wavelength in nanometres."""
165
+ return self.transition_wavelength(n_initial, n_final) * 1e9
166
+
167
+ def ionization_energy(self) -> float:
168
+ """Minimum energy (eV) to remove the outermost electron (Bohr approx)."""
169
+ if self.num_shells == 0:
170
+ return 0.0
171
+ return -self.energy_level(self.num_shells)
172
+
173
+ # ----- electrostatic force on electron -----
174
+
175
+ def coulomb_force_on_electron(self, n: int) -> float:
176
+ """Attractive Coulomb force on an electron in shell n, in newtons."""
177
+ r = self.orbital_radius(n)
178
+ return COULOMB_CONSTANT * (self.protons * ELECTRON_CHARGE**2) / r**2
179
+
180
+ # ----- representation -----
181
+
182
+ def __repr__(self):
183
+ iso = f"-{self.mass_number}" if self.mass_number != round(self.standard_atomic_weight) else ""
184
+ charge_str = ""
185
+ if self.charge > 0:
186
+ charge_str = f" (+{self.charge})"
187
+ elif self.charge < 0:
188
+ charge_str = f" ({self.charge})"
189
+ return (f"BohrAtom({self.symbol}{iso}, Z={self.atomic_number}, "
190
+ f"e={self.num_electrons}{charge_str}, "
191
+ f"shells={self.shell_config})")
192
+
193
+ def summary(self) -> str:
194
+ """Return a human-readable summary of the atom."""
195
+ lines = [
196
+ f"{'='*50}",
197
+ f" {self.name} ({self.symbol})",
198
+ f" Atomic number (Z): {self.atomic_number}",
199
+ f" Mass number (A): {self.mass_number}",
200
+ f" Protons: {self.protons} Neutrons: {self.neutrons}",
201
+ f" Electrons: {self.num_electrons} Charge: {self.charge:+d}",
202
+ f" Shell configuration: {self.shell_config}",
203
+ f"{'='*50}",
204
+ f" Shell Electrons Radius (pm) Energy (eV)",
205
+ f" {'-'*46}",
206
+ ]
207
+ for i, count in enumerate(self.shell_config, start=1):
208
+ r = self.orbital_radius_pm(i)
209
+ e = self.energy_level(i)
210
+ lines.append(f" n={i:<4} {count:<10} {r:>10.2f} {e:>10.4f}")
211
+ lines.append(f" {'-'*46}")
212
+ lines.append(f" Ionization energy (Bohr): {self.ionization_energy():.4f} eV")
213
+ return "\n".join(lines)
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Convenience constructors
218
+ # ---------------------------------------------------------------------------
219
+ def from_symbol(symbol: str, mass_number: int = None, charge: int = 0) -> BohrAtom:
220
+ """Create a BohrAtom from an element symbol string."""
221
+ symbol = symbol.capitalize()
222
+ if len(symbol) > 1:
223
+ symbol = symbol[0].upper() + symbol[1:].lower()
224
+ if symbol not in SYMBOL_TO_Z:
225
+ raise ValueError(f"Unknown element symbol: {symbol}")
226
+ return BohrAtom(SYMBOL_TO_Z[symbol], mass_number, charge)
227
+
228
+
229
+ def from_name(name: str, mass_number: int = None, charge: int = 0) -> BohrAtom:
230
+ """Create a BohrAtom from an element name string."""
231
+ name_lower = name.strip().lower()
232
+ for z, (sym, elem_name, _) in ELEMENTS.items():
233
+ if elem_name.lower() == name_lower:
234
+ return BohrAtom(z, mass_number, charge)
235
+ raise ValueError(f"Unknown element name: {name}")
@@ -0,0 +1,334 @@
1
+ """
2
+ Quantum Mechanical Model of the Atom
3
+
4
+ Represents atoms using the quantum mechanical framework:
5
+ - Four quantum numbers (n, l, m_l, m_s) per electron
6
+ - Electron configuration via Aufbau principle, Hund's rule, Pauli exclusion
7
+ - Slater's rules for effective nuclear charge (Z_eff)
8
+ - Approximate orbital energies for multi-electron atoms
9
+ - Known configuration exceptions for d- and f-block elements
10
+ """
11
+
12
+ import math
13
+ from molbuilder.core.constants import RYDBERG_ENERGY_EV, EV_TO_JOULES, BOHR_RADIUS_M, HBAR
14
+ from molbuilder.core.elements import ELEMENTS, SYMBOL_TO_Z, NOBLE_GASES
15
+ from molbuilder.atomic.quantum_numbers import (
16
+ QuantumState,
17
+ Subshell,
18
+ aufbau_order,
19
+ AUFBAU_EXCEPTIONS,
20
+ SUBSHELL_LETTER,
21
+ )
22
+ from molbuilder.atomic.wavefunctions import orbital_angular_momentum, angular_momentum_z
23
+
24
+
25
+ # ===================================================================
26
+ # Slater's rules for effective nuclear charge
27
+ # ===================================================================
28
+
29
+ def slater_zeff(Z: int, n: int, l: int,
30
+ config: list[tuple[int, int, int]]) -> float:
31
+ """Compute Slater's effective nuclear charge Z_eff for an electron
32
+ in subshell (n, l) of an atom with atomic number Z.
33
+
34
+ Slater grouping: (1s)(2s,2p)(3s,3p)(3d)(4s,4p)(4d)(4f)(5s,5p)...
35
+
36
+ Shielding rules:
37
+ Same group: 0.35 each (0.30 for 1s)
38
+ (n-1) group: 0.85 each [for s,p electrons]
39
+ All lower: 1.00 each [for s,p electrons]
40
+ For d,f: same group 0.35, all lower groups 1.00
41
+ """
42
+ def slater_group(ni, li):
43
+ """Assign Slater group index for sorting."""
44
+ if li <= 1:
45
+ return (ni, 0) # s and p are in the same group
46
+ else:
47
+ return (ni, li) # d, f each in their own group
48
+
49
+ target_group = slater_group(n, l)
50
+ is_sp = (l <= 1) # s or p electron
51
+
52
+ # Build list of all occupied groups with their electron counts
53
+ groups = {}
54
+ for (ni, li, count) in config:
55
+ g = slater_group(ni, li)
56
+ groups[g] = groups.get(g, 0) + count
57
+
58
+ sigma = 0.0
59
+
60
+ for g, count in groups.items():
61
+ if g == target_group:
62
+ # Electrons in the same group (exclude self)
63
+ same_count = count - 1
64
+ if same_count > 0:
65
+ s_each = 0.30 if (n == 1 and l == 0) else 0.35
66
+ sigma += same_count * s_each
67
+ elif g < target_group:
68
+ if is_sp:
69
+ # For s/p electron: (n-1) shell shields 0.85, lower shields 1.0
70
+ # The (n-1) shell includes ALL groups whose principal quantum
71
+ # number equals n-1, regardless of angular momentum (s/p/d/f).
72
+ g_n = g[0] # principal quantum number of the inner group
73
+ if g_n == n - 1:
74
+ sigma += count * 0.85
75
+ else:
76
+ sigma += count * 1.00
77
+ else:
78
+ # For d/f electron: all lower groups shield 1.0
79
+ sigma += count * 1.00
80
+
81
+ return Z - sigma
82
+
83
+
84
+ # ===================================================================
85
+ # The Quantum Atom
86
+ # ===================================================================
87
+
88
+ class QuantumAtom:
89
+ """Represents an atom using the quantum mechanical model.
90
+
91
+ Parameters
92
+ ----------
93
+ atomic_number : int
94
+ Number of protons (Z). Determines the element.
95
+ mass_number : int or None
96
+ Protons + neutrons. Defaults to rounded standard atomic weight.
97
+ charge : int
98
+ Net ionic charge (0 = neutral).
99
+ """
100
+
101
+ def __init__(self, atomic_number: int, mass_number: int = None, charge: int = 0):
102
+ if atomic_number < 1 or atomic_number > 118:
103
+ raise ValueError(f"Atomic number must be 1-118, got {atomic_number}")
104
+
105
+ self.atomic_number = atomic_number
106
+ self.protons = atomic_number
107
+ symbol, name, weight = ELEMENTS[atomic_number]
108
+ self.symbol = symbol
109
+ self.name = name
110
+ self.standard_atomic_weight = weight
111
+
112
+ self.mass_number = mass_number if mass_number is not None else round(weight)
113
+ self.neutrons = self.mass_number - self.protons
114
+ self.charge = charge
115
+ self.num_electrons = self.protons - charge
116
+
117
+ if self.num_electrons < 0:
118
+ raise ValueError("Charge exceeds proton count")
119
+
120
+ # Build ground-state electron configuration
121
+ self.subshells = self._build_configuration()
122
+ self.quantum_states = self._enumerate_quantum_states()
123
+
124
+ # ------ configuration ------
125
+
126
+ def _build_configuration(self) -> list[Subshell]:
127
+ """Build the ground-state electron configuration."""
128
+ # Check for known exceptions (neutral atoms only)
129
+ if self.charge == 0 and self.atomic_number in AUFBAU_EXCEPTIONS:
130
+ return [
131
+ Subshell(n, l, count)
132
+ for n, l, count in AUFBAU_EXCEPTIONS[self.atomic_number]
133
+ ]
134
+
135
+ # Standard Aufbau filling
136
+ order = aufbau_order()
137
+ remaining = self.num_electrons
138
+ subshells = []
139
+
140
+ for n, l in order:
141
+ if remaining <= 0:
142
+ break
143
+ capacity = 2 * (2 * l + 1)
144
+ count = min(remaining, capacity)
145
+ subshells.append(Subshell(n, l, count))
146
+ remaining -= count
147
+
148
+ return subshells
149
+
150
+ def _enumerate_quantum_states(self) -> list[QuantumState]:
151
+ """List all individual quantum states following Hund's rules."""
152
+ states = []
153
+ for ss in self.subshells:
154
+ states.extend(ss.quantum_states())
155
+ return states
156
+
157
+ # ------ configuration as tuples (for Slater's rules) ------
158
+
159
+ @property
160
+ def config_tuples(self) -> list[tuple[int, int, int]]:
161
+ """Configuration as [(n, l, count), ...]."""
162
+ return [(ss.n, ss.l, ss.electron_count) for ss in self.subshells]
163
+
164
+ # ------ string representations ------
165
+
166
+ def electron_configuration_string(self) -> str:
167
+ """Full configuration string, e.g. '1s2 2s2 2p6 3s2 3p2'."""
168
+ return " ".join(
169
+ f"{ss.label}{ss.electron_count}" for ss in self.subshells
170
+ )
171
+
172
+ def noble_gas_notation(self) -> str:
173
+ """Shorthand using noble gas core, e.g. '[Ne] 3s2 3p2'."""
174
+ core_z = 0
175
+ core_symbol = ""
176
+ electrons_accounted = 0
177
+
178
+ for z, sym in sorted(NOBLE_GASES.items()):
179
+ if z < self.atomic_number and z <= self.num_electrons:
180
+ core_z = z
181
+ core_symbol = sym
182
+
183
+ if core_z == 0:
184
+ return self.electron_configuration_string()
185
+
186
+ # Find how many subshells form the core
187
+ electrons_accounted = 0
188
+ core_end_idx = 0
189
+ for i, ss in enumerate(self.subshells):
190
+ electrons_accounted += ss.electron_count
191
+ core_end_idx = i + 1
192
+ if electrons_accounted == core_z:
193
+ break
194
+
195
+ valence_str = " ".join(
196
+ f"{ss.label}{ss.electron_count}"
197
+ for ss in self.subshells[core_end_idx:]
198
+ )
199
+ return f"[{core_symbol}] {valence_str}".strip()
200
+
201
+ # ------ valence / core ------
202
+
203
+ @property
204
+ def valence_shell(self) -> int:
205
+ """Highest principal quantum number with electrons."""
206
+ if not self.subshells:
207
+ return 0
208
+ return max(ss.n for ss in self.subshells)
209
+
210
+ @property
211
+ def valence_electrons(self) -> int:
212
+ """Number of electrons in the valence shell."""
213
+ vs = self.valence_shell
214
+ return sum(ss.electron_count for ss in self.subshells if ss.n == vs)
215
+
216
+ @property
217
+ def core_electrons(self) -> int:
218
+ """Number of core (non-valence) electrons."""
219
+ return self.num_electrons - self.valence_electrons
220
+
221
+ # ------ effective nuclear charge ------
222
+
223
+ def effective_nuclear_charge(self, n: int, l: int) -> float:
224
+ """Slater's Z_eff for an electron in subshell (n, l)."""
225
+ return slater_zeff(self.atomic_number, n, l, self.config_tuples)
226
+
227
+ # ------ energy approximations ------
228
+
229
+ def orbital_energy_eV(self, n: int, l: int) -> float:
230
+ """Approximate orbital energy using Slater's Z_eff.
231
+
232
+ E_{n,l} ~ -13.6 eV * Z_eff^2 / n^2
233
+ """
234
+ z_eff = self.effective_nuclear_charge(n, l)
235
+ return -RYDBERG_ENERGY_EV * z_eff**2 / n**2
236
+
237
+ def ionization_energy_eV(self) -> float:
238
+ """Approximate first ionization energy (Slater model), in eV.
239
+
240
+ Energy required to remove the outermost electron.
241
+ """
242
+ if not self.subshells:
243
+ return 0.0
244
+ outermost = self.subshells[-1]
245
+ return -self.orbital_energy_eV(outermost.n, outermost.l)
246
+
247
+ # ------ angular momentum ------
248
+
249
+ def total_spin_quantum_number(self) -> float:
250
+ """Total spin S = sum of m_s values (maximised by Hund's rule)."""
251
+ return abs(sum(qs.ms for qs in self.quantum_states))
252
+
253
+ def spin_multiplicity(self) -> int:
254
+ """2S + 1 -- spin multiplicity of the ground state."""
255
+ S = self.total_spin_quantum_number()
256
+ return int(2 * S + 1)
257
+
258
+ # ------ display ------
259
+
260
+ def __repr__(self):
261
+ charge_str = ""
262
+ if self.charge > 0:
263
+ charge_str = f"(+{self.charge})"
264
+ elif self.charge < 0:
265
+ charge_str = f"({self.charge})"
266
+ return (f"QuantumAtom({self.symbol}, Z={self.atomic_number}, "
267
+ f"e={self.num_electrons}{charge_str})")
268
+
269
+ def summary(self) -> str:
270
+ """Print a detailed summary of the atom's quantum description."""
271
+ lines = [
272
+ f"{'='*60}",
273
+ f" {self.name} ({self.symbol})",
274
+ f" Atomic number: {self.atomic_number}",
275
+ f" Mass number: {self.mass_number}",
276
+ f" Protons: {self.protons} Neutrons: {self.neutrons} "
277
+ f"Electrons: {self.num_electrons}",
278
+ f" Charge: {self.charge:+d}",
279
+ f"{'='*60}",
280
+ f" Electron configuration:",
281
+ f" {self.electron_configuration_string()}",
282
+ f" {self.noble_gas_notation()}",
283
+ f" Valence electrons: {self.valence_electrons}",
284
+ f" Spin multiplicity: {self.spin_multiplicity()}",
285
+ f"{'='*60}",
286
+ f" Subshell e- Z_eff Energy (eV)",
287
+ f" {'-'*48}",
288
+ ]
289
+ for ss in self.subshells:
290
+ z_eff = self.effective_nuclear_charge(ss.n, ss.l)
291
+ e_eV = self.orbital_energy_eV(ss.n, ss.l)
292
+ lines.append(
293
+ f" {ss.label:<6} {ss.electron_count:>4} "
294
+ f"{z_eff:>7.3f} {e_eV:>10.4f}"
295
+ )
296
+ lines.append(f" {'-'*48}")
297
+ lines.append(
298
+ f" Ionization energy (Slater): "
299
+ f"{self.ionization_energy_eV():.4f} eV"
300
+ )
301
+ lines.append("")
302
+
303
+ # Show all quantum states for small atoms
304
+ if self.num_electrons <= 18:
305
+ lines.append(f" All quantum states (n, l, ml, ms):")
306
+ for qs in self.quantum_states:
307
+ lines.append(f" {qs}")
308
+
309
+ return "\n".join(lines)
310
+
311
+
312
+ # ===================================================================
313
+ # Convenience constructors
314
+ # ===================================================================
315
+
316
+ def from_symbol(symbol: str, mass_number: int = None, charge: int = 0) -> QuantumAtom:
317
+ """Create a QuantumAtom from an element symbol."""
318
+ sym = symbol.strip()
319
+ if len(sym) > 1:
320
+ sym = sym[0].upper() + sym[1:].lower()
321
+ else:
322
+ sym = sym.upper()
323
+ if sym not in SYMBOL_TO_Z:
324
+ raise ValueError(f"Unknown element symbol: {symbol}")
325
+ return QuantumAtom(SYMBOL_TO_Z[sym], mass_number, charge)
326
+
327
+
328
+ def from_name(name: str, mass_number: int = None, charge: int = 0) -> QuantumAtom:
329
+ """Create a QuantumAtom from an element name."""
330
+ name_lower = name.strip().lower()
331
+ for z, (sym, elem_name, _) in ELEMENTS.items():
332
+ if elem_name.lower() == name_lower:
333
+ return QuantumAtom(z, mass_number, charge)
334
+ raise ValueError(f"Unknown element name: {name}")