Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/epac.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: epac

on:
pull_request:
paths:
- "research/epac/**"
- "libs/ucns/**"
- ".github/workflows/epac.yml"
push:
branches: [main]
paths:
- "research/epac/**"
- "libs/ucns/**"
- ".github/workflows/epac.yml"

permissions:
contents: read

jobs:
epac:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: EPAC contract tests
env:
PYTHONPATH: research/epac:libs/ucns/src
run: python -m unittest discover -s research/epac/tests -q
15 changes: 10 additions & 5 deletions research/epac/epac_atomic.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,16 +214,18 @@ def _promoted_unpaired(electrons: tuple[ElectronState, ...]) -> tuple[ElectronSt
s_pair = next((pair for pair in s_pairs_by_orbital.values() if len(pair) == 2), None)
if s_pair is None or not empty_p_m:
return tuple(unpaired)
# Promote one valence s electron into the first empty p m while preserving
# the distinct electron that remains in the s orbital.
promoted_from_s = next((item for item in s_pair if item.m_s == 1), s_pair[0])
# Promote the spin-down valence s electron into the first empty valence p
# and flip it to spin-up. The spin-up s electron stays behind, so every
# promoted unpaired electron carries m_s = +1, matching the ground-state
# unpaired convention used by _unpaired_valence.
promoted_from_s = next((item for item in s_pair if item.m_s == -1), s_pair[0])
remaining_s = next(item for item in s_pair if item.index != promoted_from_s.index)
new_p = ElectronState(
index=promoted_from_s.index,
n=valence_n,
l=1,
m_l=empty_p_m[0],
m_s=promoted_from_s.m_s,
m_s=1,
shell=f"n{valence_n}",
subshell=_subshell_name(valence_n, 1),
angular_id=_angular_id(1, empty_p_m[0]),
Expand All @@ -248,7 +250,10 @@ def _promoted_unpaired(electrons: tuple[ElectronState, ...]) -> tuple[ElectronSt
valence=True,
paired=False,
)
return tuple([unpaired_s, new_p, *[e for e in unpaired if not (e.l == 0)]])
promoted = [unpaired_s, new_p, *[e for e in unpaired if e.l != 0]]
# Canonical subshell ordering: s before p, p orbitals by ascending m_l.
promoted.sort(key=lambda electron: (electron.l, electron.m_l))
return tuple(promoted)


def atomic_record(Z: int) -> AtomicRecord:
Expand Down
50 changes: 33 additions & 17 deletions research/epac/epac_molecular.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,14 @@

from ucns.direct_mobius import native_mobius_state

from epac_atomic import AtomicRecord
from epac_dimensional_arity import (
charged_structure_readout,
geometry_from_declared_couplings,
oriented_instance_couplings,
space,
topology_structure_readout,
)
from epac_periodic import atomic_of, carried, construct_element_gonol, symbol_of
from epac_periodic import carried, construct_element_gonol, symbol_of
from epac_public_gonol import ClosedPublicGonol, PublicGonolReceipt, construct_public_gonol, replay_public_gonol


Expand Down Expand Up @@ -59,8 +58,23 @@ def _instantiate(composition: tuple[tuple[str, int], ...]) -> tuple[ClosedPublic
return tuple(instances)


def _record_for(gonol: ClosedPublicGonol) -> AtomicRecord:
return atomic_of(symbol_of(gonol))
def _parse_lm(text: str) -> tuple[tuple[int, int], ...]:
"""Parse a carried ``*-lm`` option into ``(l, m_l)`` pairs, preserving order."""
if text in ("", "none"):
return ()
pairs: list[tuple[int, int]] = []
for part in text.split(","):
l_text, m_text = part.split(":")
pairs.append((int(l_text), int(m_text)))
return tuple(pairs)


def _unpaired_lm(gonol: ClosedPublicGonol) -> tuple[tuple[int, int], ...]:
return _parse_lm(carried(gonol, "unpaired-valence-lm"))


def _promoted_lm(gonol: ClosedPublicGonol) -> tuple[tuple[int, int], ...]:
return _parse_lm(carried(gonol, "promoted-unpaired-lm"))


def _choose_center(participants: tuple[ClosedPublicGonol, ...]) -> ClosedPublicGonol | None:
Expand All @@ -79,15 +93,21 @@ def _choose_center(participants: tuple[ClosedPublicGonol, ...]) -> ClosedPublicG
return None


def _attachment_set(record: AtomicRecord, needed: int) -> tuple[tuple[int, int], ...]:
ground = tuple((e.l, e.m_l) for e in record.unpaired_valence)
def _attachment_set(gonol: ClosedPublicGonol, needed: int) -> tuple[tuple[int, int], ...]:
"""Attachment sites derive from the already-closed element gonol.

No periodic-table relookup: the element gonol's carried promotion evidence
is the only promotion source for molecular construction.
"""

ground = _unpaired_lm(gonol)
if len(ground) >= needed:
return ground[:needed]
promoted = tuple((e.l, e.m_l) for e in record.promoted_unpaired_valence)
promoted = _promoted_lm(gonol)
if len(promoted) >= needed:
return promoted[:needed]
raise ValueError(
f"{record.symbol} has {len(ground)} unpaired valence electrons; "
f"{symbol_of(gonol)} has {len(ground)} unpaired valence electrons; "
f"{needed} attachment sites were requested"
)

Expand Down Expand Up @@ -182,22 +202,18 @@ def construct_molecule(formula: str) -> MolecularConstruction:
center_sites: tuple[tuple[int, int], ...] = ()
if len(participants) != 2:
raise ValueError("symmetric affixiation is declared only for two equal atoms")
left, right = (_record_for(participants[0]), _record_for(participants[1]))
ligand_sites = (
tuple((e.l, e.m_l) for e in left.unpaired_valence),
tuple((e.l, e.m_l) for e in right.unpaired_valence),
_unpaired_lm(participants[0]),
_unpaired_lm(participants[1]),
)
used_promotion = False
else:
ligands = tuple(item for item in participants if item is not center)
center_record = _record_for(center)
ground = tuple((e.l, e.m_l) for e in center_record.unpaired_valence)
ligand_sites = tuple(
tuple((e.l, e.m_l) for e in _record_for(item).unpaired_valence) for item in ligands
)
ground = _unpaired_lm(center)
ligand_sites = tuple(_unpaired_lm(item) for item in ligands)
needed = sum(len(sites) for sites in ligand_sites)
used_promotion = needed > len(ground)
center_sites = _attachment_set(center_record, needed)
center_sites = _attachment_set(center, needed)
mobius = _mobius_coupling(
participants=participants,
center=center,
Expand Down
75 changes: 75 additions & 0 deletions research/epac/tests/test_atomic_promotion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from __future__ import annotations

import sys
import unittest
from pathlib import Path

EPAC_ROOT = Path(__file__).resolve().parents[1]
STACK_ROOT = EPAC_ROOT.parents[1]
sys.path.insert(0, str(EPAC_ROOT))
sys.path.insert(0, str(STACK_ROOT / "libs" / "ucns" / "src"))

from epac_atomic import atomic_record
from epac_periodic import construct_element_gonol, replay_element_gonol


class AtomicPromotionTest(unittest.TestCase):
def test_promoted_carbon_unpaired_accounting_and_ordering(self) -> None:
carbon = atomic_record(6)
self.assertEqual(carbon.configuration, "1s2.2s2.2p2")
self.assertEqual(tuple((e.l, e.m_l) for e in carbon.unpaired_valence), ((1, 1), (1, 0)))
promoted = carbon.promoted_unpaired_valence
self.assertEqual(len(promoted), 4)
# Every promoted unpaired electron uses the m_s = +1 convention.
self.assertTrue(all(e.m_s == 1 for e in promoted))
self.assertEqual(len({e.index for e in promoted}), len(promoted))
# Canonical subshell ordering: s before p, p orbitals ascending m_l.
self.assertEqual(
tuple((e.l, e.m_l) for e in promoted),
((0, 0), (1, -1), (1, 0), (1, 1)),
)
self.assertEqual({e.subshell for e in promoted}, {"2s", "2p"})

def test_promoted_beryllium_unpaired_accounting_and_ordering(self) -> None:
beryllium = atomic_record(4)
self.assertEqual(beryllium.configuration, "1s2.2s2")
promoted = beryllium.promoted_unpaired_valence
self.assertEqual(len(promoted), 2)
self.assertTrue(all(e.m_s == 1 for e in promoted))
self.assertEqual(tuple((e.l, e.m_l) for e in promoted), ((0, 0), (1, 1)))

def test_ordinary_atoms_do_not_promote_without_an_empty_valence_p(self) -> None:
# Helium has no valence shell; oxygen and nitrogen have no empty
# valence p orbital, so their promoted sets equal their ground sets.
helium = atomic_record(2)
oxygen = atomic_record(8)
nitrogen = atomic_record(7)
self.assertEqual(helium.promoted_unpaired_valence, ())
self.assertEqual(helium.unpaired_valence, ())
self.assertEqual(
tuple((e.l, e.m_l) for e in oxygen.promoted_unpaired_valence),
((1, 0), (1, -1)),
)
self.assertEqual(
tuple((e.l, e.m_l) for e in oxygen.promoted_unpaired_valence),
tuple((e.l, e.m_l) for e in oxygen.unpaired_valence),
)
self.assertEqual(len(nitrogen.promoted_unpaired_valence), 3)
self.assertEqual(
tuple((e.l, e.m_l) for e in nitrogen.promoted_unpaired_valence),
tuple((e.l, e.m_l) for e in nitrogen.unpaired_valence),
)

def test_configuration_serialization_round_trip(self) -> None:
carbon = construct_element_gonol("C")
options = dict(carbon.gonol.carried_options)
self.assertEqual(options["electron-configuration"], "1s2.2s2.2p2")
self.assertEqual(options["unpaired-valence-lm"], "1:1,1:0")
self.assertEqual(options["promoted-unpaired-count"], "4")
self.assertEqual(options["promoted-unpaired-lm"], "0:0,1:-1,1:0,1:1")
replayed = replay_element_gonol(carbon)
self.assertEqual(carbon.receipt_digest, replayed.receipt_digest)


if __name__ == "__main__":
unittest.main()
4 changes: 2 additions & 2 deletions research/epac/tests/test_molecular_affixiation.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ def test_unpaired_valence_and_shells_are_used(self) -> None:
self.assertFalse(water["ligand_has_p"])
self.assertEqual(water["center_used_atomic_promotion"], False)
self.assertEqual(methane["center_used_atomic_promotion"], True)
self.assertEqual(methane["center_unpaired_lm"], ["0:0", "1:-1", "1:1", "1:0"])
self.assertEqual(methane["center_unpaired_lm"], ["0:0", "1:-1", "1:0", "1:1"])
self.assertTrue(carbon_dioxide["ligand_has_p"])
self.assertEqual(carbon_dioxide["center_unpaired_lm"], ["0:0", "1:-1", "1:1", "1:0"])
self.assertEqual(carbon_dioxide["center_unpaired_lm"], ["0:0", "1:-1", "1:0", "1:1"])
self.assertEqual(carbon_dioxide["center_attachment_site_count"], 4)
self.assertEqual(carbon_dioxide["ligand_attachment_site_count"], 4)

Expand Down