Domain: Chemistry — Physical Chemistry

Problem:
Implement an algorithm to compute the **compressibility factor** $Z$ for a system of **dipolar** hard spheres with depletion-mediated attractions using a hybrid analytical-simulation approach. The system must simultaneously handle hard-core, short-range depletion, and long-range dipolar interactions under periodic boundary conditions.

The system consists of $N = 32$ hard spheres of diameter $\sigma = 1.0$ at reduced density $\rho^* = 0.4$ and temperature $T^* = 1.0$, confined in a cubic box with periodic boundaries. **The box side length $L$ is explicitly defined by the density as $L = \sigma(N/\rho^*)^{1/3}$.** Each particle carries a fixed dipole moment $\mu^* = 1.5$ aligned along the z-axis and experiences competing interactions:

**Hard Core Repulsion:**
\[
u_{\text{HS}}(r) = \begin{cases} \infty & r < \sigma \\ 0 & r \geq \sigma \end{cases}
\]

**Depletion Attraction** (Asakura-Ooshima):
\[
u_{\text{dep}}(r) = -P_{\text{dep}}^* \cdot \frac{\pi}{12}(\sigma + \sigma_{\text{dep}} - r)^2(r - \sigma + 2\sigma_{\text{dep}})
\]
for $\sigma \leq r < \sigma + \sigma_{\text{dep}}$, with $P_{\text{dep}}^* = 0.5$, $\sigma_{\text{dep}} = 0.2\sigma$.

**Long-Range Dipolar Interaction:**
\[
u_{\text{dipole}}(\mathbf{r}_{ij}) = \frac{\mathbf{\mu}_i \cdot \mathbf{\mu}_j}{r_{ij}^3} - \frac{3(\mathbf{\mu}_i \cdot \mathbf{r}_{ij})(\mathbf{\mu}_j \cdot \mathbf{r}_{ij})}{r_{ij}^5}
\]
requiring **Ewald summation** under **conducting ($\epsilon'=\infty$) boundary conditions**. The **standard Ewald virial expressions** for the pressure (including real-space and reciprocal-space contributions) must be used. Parameters for the summation are: real-space cutoff $r_{\text{cut}} = L/2$, convergence parameter $\alpha = 5.0/L$, and reciprocal-space cutoff $k_{\text{max}}^2 = 20$.

The compressibility factor combines analytical and numerical contributions:
\[
Z = Z_{\text{HS}} + Z_{\text{continuous}}
\]
where $Z_{\text{HS}}$ is the Carnahan-Starling hard-sphere contribution and $Z_{\text{continuous}}$ is the virial contribution from both depletion and dipolar forces obtained through Monte Carlo simulation with 5000 equilibration steps and 10000 production steps using maximum displacement $\delta r = 0.05\sigma$. 

**Implementation Constraints & Algorithms:**

1.  **Initialization:** Initialize the 32 particle positions on a **Face-Centered Cubic (FCC) lattice** to ensure no initial hard-sphere overlaps at this density.
2.  **Monte Carlo Update Logic:**
    * Perform single-particle trial moves.
    * **Energy:** For the acceptance criterion, compute the energy change ($\Delta U$) efficiently by calculating only the difference between the old and new states (do not recalculate the full system energy for the trial).
    * **Virial:** Upon **acceptance** of a move, **recalculate the total system virial from scratch** using the new full configuration. Do not attempt to compute the virial change incrementally.
3.  **Ewald Terms:**
    * Include the standard Ewald self-energy correction: $U_{self} = -\frac{\alpha}{\sqrt{\pi}} \sum \mu_i^2$ (or the equivalent Gaussian width form used in your derivation).
    * **Real-Space Virial:** Compute the real-space virial term ($W_{real}$) for each pair using the analytical derivative relationship: $W_{real}(r) = 3U_{real}(r) - \text{correction}$, derived from the explicit differentiation of the screened dipolar potential.


**Final Output:** The compressibility factor $Z$ reported to three decimal places without any extra print statements.

**The final answer must be the numerical value of the compressibility factor formatted to three decimal places.**

Answer: 2.708

Solution code:
import numpy as np
from scipy.special import erfc
import sys

np.random.seed(42)

def solve():

    # =====================================
    # --- System Parameters ---
    # =====================================
    N = 32
    rho_star = 0.4
    T_star = 1.0
    beta = 1.0 / T_star
    sigma = 1.0
    sigma_sq = sigma**2
    sigma_dep = 0.2 * sigma
    P_dep_star = 0.5
    mu_star = 1.5
    mu_sq = mu_star**2

    # Simulation parameters
    n_equil = 5000
    n_prod = 10000
    n_total = n_equil + n_prod
    delta_r = 0.05 * sigma

    # Ewald parameters from prompt
    volume = N / rho_star
    L = volume ** (1/3.0)
    L_half = L / 2.0
    alpha = 5.0 / L
    alpha_sq = alpha**2
    r_cut = L / 2.0
    r_cut_sq = r_cut**2
    k_max_sq = 20.0
    
    # Pre-calculate depletion constants
    sigma_plus_dep = sigma + sigma_dep
    dep_const = -P_dep_star * np.pi / 12.0

    # =====================================
    # --- Initialization ---
    # =====================================

    def initialize_fcc_positions(N_particles, box_L):
        """Initializes N particles on an FCC lattice."""
        n_cells_per_dim = int(np.ceil((N_particles / 4.0)**(1/3.0)))
        if n_cells_per_dim == 0:
            return np.empty((0, 3))
            
        cell_size = box_L / n_cells_per_dim
        basis = np.array([[0.0, 0.0, 0.0], [0.5, 0.5, 0.0],
                          [0.5, 0.0, 0.5], [0.0, 0.5, 0.5]]) * cell_size
        
        positions_list = []
        count = 0
        for i in range(n_cells_per_dim):
            for j in range(n_cells_per_dim):
                for k in range(n_cells_per_dim):
                    for v in basis:
                        if count < N_particles:
                            pos = np.array([i, j, k]) * cell_size + v
                            positions_list.append(pos)
                            count += 1
        return np.array(positions_list)

    positions = initialize_fcc_positions(N, L)
    
    # Dipoles are not needed as an array, since mu_star is constant and
    # along z. We just need mu_sq.

    # =====================================
    # --- Helper Functions ---
    # =====================================
    
    def pbc_vec(vec):
        """Applies periodic boundary conditions to a vector."""
        return vec - L * np.round(vec / L)

    def generate_k_vectors(box_L, alpha_val, k_max_sq_val):
        """Generates reciprocal-space k-vectors and Ewald prefactors."""
        k_vectors_list = []
        n_max = int(np.ceil(np.sqrt(k_max_sq_val) * box_L / (2.0 * np.pi)))
        if n_max == 0:
            n_max = 1 # Ensure at least one loop

        k_prefactor = 2.0 * np.pi / box_L

        for nx in range(-n_max, n_max + 1):
            for ny in range(-n_max, n_max + 1):
                for nz in range(-n_max, n_max + 1):
                    if nx == 0 and ny == 0 and nz == 0:
                        continue
                    k_vec = np.array([nx, ny, nz]) * k_prefactor
                    k_sq = np.dot(k_vec, k_vec)
                    if k_sq <= k_max_sq_val:
                        k_vectors_list.append(k_vec)
        
        k_vecs_arr = np.array(k_vectors_list)
        if k_vecs_arr.shape[0] == 0:
             return k_vecs_arr, np.array([]), np.array([])
             
        k_sqs = np.sum(k_vecs_arr**2, axis=1)
        k_zs = k_vecs_arr[:, 2]
        
        exp_term = np.exp(-k_sqs / (4.0 * alpha_val**2))
        common_factor = (2.0 * np.pi * mu_sq / volume) * (exp_term / k_sqs)
        
        # Ewald prefactor for ENERGY (fixed-z, conducting)
        k_factor_E = common_factor * k_zs**2
        
        # Ewald prefactor for VIRIAL (fixed-z, conducting)
        k_factor_W = common_factor * (2.0 * k_zs**2 * (1.0 - k_sqs / (4.0 * alpha_val**2)))
        
        return k_vecs_arr, k_factor_E, k_factor_W

    # =====================================
    # --- Single-Pair Potentials & Virials ---
    # =====================================

    def u_dep(r):
        """Depletion potential U(r)."""
        if r < sigma or r >= sigma_plus_dep:
            return 0.0
        A = (sigma_plus_dep - r)
        B = (r - sigma + 2.0 * sigma_dep)
        return dep_const * A**2 * B

    def w_dep(r):
        """Depletion virial W(r) = -r * dU/dr."""
        if r < sigma or r >= sigma_plus_dep:
            return 0.0
        A = (sigma_plus_dep - r)
        B = (r - sigma + 2.0 * sigma_dep)
        # W = r * F = -r * (dU/dr)
        # d(A^2*B)/dr = A^2 * (1) + B * (2*A*(-1)) = A^2 - 2*A*B
        d_U_dr_term = (A**2 - 2.0 * A * B)
        return -r * (dep_const * d_U_dr_term)

    def u_real(r_vec, r_norm):
        """Real-space Ewald U(r) for fixed-z dipoles."""
        r_sq = r_norm**2
        z = r_vec[2]
        cos2_theta = z**2 / r_sq
        C_D = mu_sq * (1.0 - 3.0 * cos2_theta)
        
        ar = alpha * r_norm
        ar_sq = ar**2
        exp_term = np.exp(-ar_sq)
        erfc_term = erfc(ar)
        
        term1 = erfc_term / (r_norm**3)
        term2 = (2.0 * alpha / np.sqrt(np.pi)) * exp_term / r_sq
        
        return C_D * (term1 + term2)

    def w_real(r_vec, r_norm, u_r_pair):
        """Real-space Ewald W(r) = -r * dU/dr."""
        r_sq = r_norm**2
        z = r_vec[2]
        cos2_theta = z**2 / r_sq
        C_D = mu_sq * (1.0 - 3.0 * cos2_theta)
        
        ar = alpha * r_norm
        ar_sq = ar**2
        exp_term = np.exp(-ar_sq)

        # Virial-specific correction term
        virial_term = C_D * (2.0 * alpha / np.sqrt(np.pi)) * exp_term * (2.0 * alpha_sq + 2.0 / r_sq)
        
        # W_real = 3*U_real - correction
        return 3.0 * u_r_pair - virial_term

    # =====================================
    # --- Total System Calculations ---
    # =====================================

    def calc_pair_terms(pos_array):
        """Calculates total U and W for all pair interactions (dep + real)."""
        U_d_total = 0.0
        W_d_total = 0.0
        U_r_total = 0.0
        W_r_total = 0.0
        for i in range(N):
            for j in range(i + 1, N):
                r_vec_ij = pbc_vec(pos_array[i] - pos_array[j])
                r_ij = np.linalg.norm(r_vec_ij)
                
                if r_ij < sigma:
                    # This should not happen if called from a valid state
                    return float('inf'), 0.0, 0.0, 0.0
                
                # Depletion
                U_d_total += u_dep(r_ij)
                W_d_total += w_dep(r_ij)
                
                # Ewald Real
                if r_ij < r_cut:
                    u_r_pair = u_real(r_vec_ij, r_ij)
                    U_r_total += u_r_pair
                    W_r_total += w_real(r_vec_ij, r_ij, u_r_pair)
                    
        return U_d_total, W_d_total, U_r_total, W_r_total

    def calc_k_space_sums(pos_array, k_vecs_arr):
        """Calculates the k-space structure factor sums."""
        # k_dot_r_matrix has shape (N_particles, N_k_vectors)
        k_dot_r_matrix = pos_array @ k_vecs_arr.T
        C_sums = np.sum(np.cos(k_dot_r_matrix), axis=0)
        S_sums = np.sum(np.sin(k_dot_r_matrix), axis=0)
        return C_sums, S_sums, k_dot_r_matrix

    def calc_U_rec_from_sums(C_sums, S_sums, k_factor_E):
        """Calculates U_rec from pre-calculated sums."""
        return np.sum(k_factor_E * (C_sums**2 + S_sums**2))

    def calc_W_rec_from_sums(pos_array, C_sums, S_sums, k_dot_r_matrix, k_vecs_arr, k_factor_W):
        """Calculates W_rec from pre-calculated sums."""
        r_dot_k_matrix = k_dot_r_matrix
        
        cos_kr = np.cos(r_dot_k_matrix)
        sin_kr = np.sin(r_dot_k_matrix)
        
        # Inner sum: (S_sums[k] * cos_kr[i,k] - C_sums[k] * sin_kr[i,k])
        inner_term = S_sums * cos_kr - C_sums * sin_kr
        
        # Multiply by r_dot_k[i,k] and sum over i
        virial_sum_per_k = np.sum(r_dot_k_matrix * inner_term, axis=0)
        
        return np.sum(k_factor_W * virial_sum_per_k)

    def calc_U_self():
        """Calculates the Ewald self-energy correction (FIXED)."""
        # Corrected formula with alpha^3
        return -(2.0 * alpha**3 / (3.0 * np.sqrt(np.pi))) * N * mu_sq

    # =====================================
    # --- Efficient Delta-U Functions ---
    # =====================================
    
    def calc_delta_pair_U(pos_array, i_moved, r_new_i):
        """Calculates delta-U for pair terms (dep + real) for particle i_moved."""
        delta_U_d = 0.0
        delta_U_r = 0.0
        r_old_i = pos_array[i_moved]
        
        for j in range(N):
            if i_moved == j:
                continue
                
            # Old contribution
            r_vec_old = pbc_vec(r_old_i - pos_array[j])
            r_old = np.linalg.norm(r_vec_old)
            delta_U_d -= u_dep(r_old)
            if r_old < r_cut:
                delta_U_r -= u_real(r_vec_old, r_old)
            
            # New contribution
            r_vec_new = pbc_vec(r_new_i - pos_array[j])
            r_new = np.linalg.norm(r_vec_new)
            delta_U_d += u_dep(r_new)
            if r_new < r_cut:
                delta_U_r += u_real(r_vec_new, r_new)
                
        return delta_U_d, delta_U_r

    def calc_delta_U_rec(r_old, r_new, C_sums_old, S_sums_old, k_vecs_arr, k_factor_E):
        """Calculates delta-U for reciprocal space."""
        k_dot_r_old = np.dot(k_vecs_arr, r_old)
        k_dot_r_new = np.dot(k_vecs_arr, r_new)
        
        cos_old = np.cos(k_dot_r_old)
        sin_old = np.sin(k_dot_r_old)
        cos_new = np.cos(k_dot_r_new)
        sin_new = np.sin(k_dot_r_new)
        
        C_sums_new = C_sums_old - cos_old + cos_new
        S_sums_new = S_sums_old - sin_old + sin_new
        
        U_rec_old = np.sum(k_factor_E * (C_sums_old**2 + S_sums_old**2))
        U_rec_new = np.sum(k_factor_E * (C_sums_new**2 + S_sums_new**2))
        
        return U_rec_new - U_rec_old, C_sums_new, S_sums_new

    # =====================================
    # --- Monte Carlo Simulation ---
    # =====================================
    
    # Pre-calculate k-vectors and Ewald factors
    k_vectors, k_fact_E, k_fact_W = generate_k_vectors(L, alpha, k_max_sq)
    
    # --- Initial Full Calculation ---
    (U_dep_curr, W_dep_curr, 
     U_real_curr, W_real_curr) = calc_pair_terms(positions)
     
    (C_sums_curr, S_sums_curr, 
     k_dot_r_curr) = calc_k_space_sums(positions, k_vectors)
     
    U_rec_curr = calc_U_rec_from_sums(C_sums_curr, S_sums_curr, k_fact_E)
    W_rec_curr = calc_W_rec_from_sums(positions, C_sums_curr, S_sums_curr, 
                                      k_dot_r_curr, k_vectors, k_fact_W)
    U_self_curr = calc_U_self()

    # Total energy and virial for the initial state
    U_total = U_dep_curr + U_real_curr + U_rec_curr + U_self_curr
    W_total = W_dep_curr + W_real_curr + W_rec_curr
    
    # Accumulator for production
    W_accumulator = 0.0
    prod_steps_count = 0
    
    # --- MC Loop ---
    for step in range(n_total):
        # 1. Select particle and get old position
        i_move = np.random.randint(N)
        r_old = positions[i_move].copy()
        
        # 2. Trial move
        displacement = (np.random.rand(3) - 0.5) * 2.0 * delta_r
        r_new = pbc_vec(r_old + displacement) # Apply PBC to position
        
        # 3. Hard Core Check
        overlap = False
        for j in range(N):
            if i_move == j:
                continue
            r_vec_trial = pbc_vec(r_new - positions[j])
            if np.linalg.norm(r_vec_trial) < sigma:
                overlap = True
                break
                
        if overlap:
            # --- AUTO-REJECT ---
            # Sample the current (old) state if in production
            if step >= n_equil:
                W_accumulator += W_total
                prod_steps_count += 1
            continue

        # 4. Calculate Delta U (Continuous)
        delta_U_d, delta_U_r = calc_delta_pair_U(positions, i_move, r_new)
        delta_U_rec, C_sums_trial, S_sums_trial = calc_delta_U_rec(
            r_old, r_new, C_sums_curr, S_sums_curr, k_vectors, k_fact_E
        )
        
        delta_U = delta_U_d + delta_U_r + delta_U_rec
        
        # 5. Metropolis-Hastings
        if delta_U <= 0 or np.random.rand() < np.exp(-delta_U * beta):
            # --- ACCEPT ---
            positions[i_move] = r_new
            U_total += delta_U
            C_sums_curr = C_sums_trial
            S_sums_curr = S_sums_trial
            
            # Must recalculate total virial from scratch
            (U_dep_curr, W_dep_curr, 
             U_real_curr, W_real_curr) = calc_pair_terms(positions)
            
            (_, _, k_dot_r_curr) = calc_k_space_sums(positions, k_vectors)
            
            W_rec_curr = calc_W_rec_from_sums(
                positions, C_sums_curr, S_sums_curr, k_dot_r_curr, k_vectors, k_fact_W
            )
            W_total = W_dep_curr + W_real_curr + W_rec_curr
            
        # --- REJECT (or after accept) ---
        # 6. Sample the (potentially new) state
        if step >= n_equil:
            W_accumulator += W_total
            prod_steps_count += 1

    # =====================================
    # --- Final Compressibility Factor ---
    # =====================================
    
    # Hard sphere contribution (Carnahan-Starling)
    eta = (np.pi / 6.0) * rho_star * (sigma**3)
    Z_HS = (1.0 + eta + eta**2 - eta**3) / (1.0 - eta)**3
    
    # Continuous contribution
    # Note: 1000 steps is very short and will have high statistical error
    avg_W_continuous = W_accumulator / prod_steps_count
    
    # Z_continuous = <W_continuous> / (3 * N * kT)
    Z_continuous = avg_W_continuous / (3.0 * N * T_star)
    
    # Total Z
    Z_total = Z_HS + Z_continuous
    
    print(f"{Z_total:.3f}")

if __name__ == "__main__":
    solve()