codex/hydration-shell-charge-orientation.py

hydration-shell-charge-orientation.py
"""Original qualitative hydration schematic; positions are not molecular data.

Tested with Python 3.14, matplotlib 3.10.7 and numpy 2.3.5.
Run from _media; the opaque PNG is written to the current directory.
"""

from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.patches import Circle
import numpy as np


def water_geometry(angle, positive, radius=1.65):
    """Return arbitrary 2D atom coordinates with the appropriate polar end inward."""
    radial = np.array([np.cos(angle), np.sin(angle)])
    tangent = np.array([-np.sin(angle), np.cos(angle)])
    oxygen = radius * radial
    direction = radial if positive else -radial
    half_angle = np.deg2rad(52.25)
    hydrogens = np.array([
        oxygen + 0.40 * (np.cos(half_angle) * direction + sign * np.sin(half_angle) * tangent)
        for sign in [-1, 1]
    ])
    return oxygen, hydrogens


def draw_water(ax, oxygen, hydrogens):
    for hydrogen in hydrogens:
        ax.plot(*np.array([oxygen, hydrogen]).T, color="#7b8490", lw=2, zorder=2)
        ax.add_patch(Circle(hydrogen, 0.10, facecolor="white", edgecolor="#677382", lw=1.2, zorder=4))
    ax.add_patch(Circle(oxygen, 0.16, facecolor="#d64945", edgecolor="#a73131", lw=1, zorder=5))


def main():
    plt.rcParams.update({"font.size": 12, "font.family": "DejaVu Sans"})
    fig, axes = plt.subplots(1, 2, figsize=(10.8, 6.0), facecolor="white")
    fig.subplots_adjust(left=0.04, right=0.96, bottom=0.22, top=0.75, wspace=0.12)
    for ax, positive in zip(axes, [True, False]):
        ax.set_aspect("equal")
        ax.set_xlim(-2.7, 2.7)
        ax.set_ylim(-2.55, 2.55)
        ax.axis("off")
        ax.add_patch(Circle((0, 0), 2.13, fill=False, edgecolor="#a4aebb", ls=(0, (4, 4)), lw=1.3))
        ax.add_patch(Circle((0, 0), 0.55, facecolor="#e8f0fb" if positive else "#f1eafb", edgecolor="#526780", lw=1.5))
        ax.text(0, 0, "$+e$" if positive else "$-e$", ha="center", va="center", fontsize=25)
        # Five selected glyphs illustrate polarity; this is not a coordination count.
        for angle in np.deg2rad([18, 90, 162, 234, 306]):
            oxygen, hydrogens = water_geometry(angle, positive)
            oxygen_r = np.linalg.norm(oxygen)
            hydrogen_r = np.linalg.norm(hydrogens, axis=1)
            assert np.all(hydrogen_r > oxygen_r) if positive else np.all(hydrogen_r < oxygen_r)
            draw_water(ax, oxygen, hydrogens)
        ax.set_title("Cation: oxygen end inward" if positive else "Anion: hydrogen end inward", fontsize=14, pad=16)
        ax.text(0, -2.50, "Ion retains its positive charge" if positive else "Ion retains its negative charge", ha="center", fontsize=12)
    fig.suptitle("Hydration changes the surroundings of an ion", fontsize=18, y=0.95)
    fig.text(0.5, 0.86, "Selected water molecules in a schematic first shell", ha="center", color="#4d5968", fontsize=13)
    fig.text(0.5, 0.15, r"Red: oxygen ($\delta^-$)     White: hydrogen ($\delta^+$)     Each water molecule is neutral", ha="center", fontsize=12)
    fig.text(0.5, 0.08, "Positions and distances are illustrative; five glyphs do not specify a coordination number.", ha="center", fontsize=11)
    fig.text(0.5, 0.035, "Real shells fluctuate and exchange molecules with the surrounding water.", ha="center", fontsize=11)
    fig.savefig(Path.cwd() / (Path(__file__).stem + ".png"), dpi=110, facecolor="white", transparent=False)
    plt.close(fig)


if __name__ == "__main__":
    main()