codex/molecular-dipole-components-and-cancellation.py

molecular-dipole-components-and-cancellation.py
"""Compare exact charge moments with bond-vector sums for a toy AB2 model.

Tested with Python 3.14, matplotlib 3.10.7 and numpy 2.3.5.
Run from _media; output is an opaque white PNG in the current directory.
"""
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Arc

BLUE, ORANGE, RED = '#2468a0', '#bb6425', '#b53848'
plt.rcParams.update({'font.size': 12, 'font.family': 'DejaVu Sans'})
fig, axes = plt.subplots(2, 2, figsize=(10, 7.4), dpi=120)
fig.patch.set_facecolor('white')
for col, angle in enumerate((180, 120)):
    half = np.deg2rad(angle/2)
    positions = np.array([[-np.sin(half), np.cos(half)], [np.sin(half), np.cos(half)]])
    dipoles = -positions
    ax = axes[0, col]
    ax.set(xlim=(-1.5, 1.5), ylim=(-0.7, 1.12), aspect='equal')
    ax.axis('off')
    ax.set_title(f'{angle}°: '+('linear and symmetric' if col == 0 else 'bent, equal bond moments'),
                 fontsize=14, pad=12, weight='bold')
    for pos in positions:
        ax.plot([0, pos[0]], [0, pos[1]], color='#bbbbbb', lw=3, zorder=0)
        # Arrows start at negative terminals and point toward the positive centre.
        ax.annotate('', xy=0.16*pos, xytext=0.78*pos,
                    arrowprops={'arrowstyle': '->', 'color': BLUE, 'lw': 2.8})
        ax.add_patch(Circle(pos, 0.12, facecolor='white', edgecolor=BLUE, lw=2))
        ax.text(*pos, 'B', ha='center', va='center', weight='bold')
        ax.text(pos[0], pos[1]+0.21, r'$-q$', ha='center', color=BLUE, fontsize=14)
    ax.add_patch(Circle((0, 0), 0.13, facecolor='white', edgecolor=ORANGE, lw=2))
    ax.text(0, 0, 'A', ha='center', va='center', weight='bold')
    ax.text(0, -0.29, r'$+2q$', ha='center', color=ORANGE, fontsize=14)
    if col:
        ax.add_patch(Arc((0, 0), 0.65, 0.65, theta1=30, theta2=150, color='#666666', lw=1.2))
        ax.text(0, 0.44, r'$\theta$', ha='center', color='#555555', fontsize=14)
    ax.text(0, -0.59, r'Total charge $=0$; each bond magnitude $p_0=qr$', ha='center', fontsize=11)
    ax = axes[1, col]
    ax.set(xlim=(-1.5, 1.5), ylim=(-1.37, 0.42), aspect='equal')
    ax.axis('off')
    ax.text(0, 0.25, 'Vectors translated to a common origin', ha='center', fontsize=12)
    ax.scatter([0], [0], s=18, color='#333333', zorder=4)
    for k, vector in enumerate(dipoles):
        ax.annotate('', xy=vector, xytext=(0, 0),
                    arrowprops={'arrowstyle': '->', 'color': BLUE, 'lw': 2.6})
        x, y = vector
        ax.text(x*1.12, y-0.10, rf'$\mathbf{{p}}_{k+1}$', ha='center', fontsize=14, color=BLUE)
        if col:
            ax.plot([0, x], [y, y], color='#777777', ls='--', lw=1.2)
            ax.plot([x, x], [0, y], color='#777777', ls='--', lw=1.2)
    if col:
        total = dipoles.sum(axis=0)
        ax.annotate('', xy=total, xytext=(0, 0),
                    arrowprops={'arrowstyle': '->', 'color': RED, 'lw': 3})
        ax.text(0.14, -0.93, r'$\mathbf{p}_{\rm mol}$', color=RED, fontsize=14)
        ax.text(0, -1.28, r'$|\mathbf{p}_{\rm mol}|=2p_0\cos 60^\circ=p_0$', ha='center', fontsize=14)
    else:
        ax.text(0, -0.58, r'$\mathbf{p}_1+\mathbf{p}_2=\mathbf{0}$', ha='center', fontsize=16, color=RED)
        ax.text(0, -1.0, 'Equal and opposite: complete cancellation', ha='center', fontsize=12)
fig.suptitle('Molecular dipoles: components cancel or add', fontsize=17, weight='bold', y=0.975)
fig.text(0.5, 0.027, 'All arrows use the physical convention: negative → positive.\n'
         'Original three-site model; 120° is stipulated, not the water bond angle.',
         ha='center', fontsize=11, linespacing=1.6)
fig.subplots_adjust(left=0.05, right=0.97, top=0.86, bottom=0.12, hspace=0.2, wspace=0.17)
fig.savefig(Path.cwd() / (Path(__file__).stem+'.png'), facecolor='white')
plt.close(fig)