EN | JP | Last sync: 2026-01-21

Chapter 4: Stability Evaluation Methods

Quantifying Dispersion Quality and Predicting Long-Term Stability

Reading Time: 30-35 minutes Difficulty: Advanced Code Examples: 7

Learning Objectives

4.1 Zeta Potential Measurement

Zeta potential (ζ) is the electrical potential at the slipping plane of a particle in solution. It is the primary indicator of electrostatic stability.

Stability Guidelines

|ζ| (mV)StabilityRecommendation
>60ExcellentLong-term stable
40-60GoodStable for months
30-40ModerateMay require optimization
20-30Incipient instabilityAdd stabilizer
<20PoorRapid aggregation expected

Example 1: Zeta Potential Analysis and pH Titration

import numpy as np
import matplotlib.pyplot as plt

# ===================================
# Example 1: Zeta Potential Analysis
# ===================================

class ZetaPotentialAnalyzer:
    """Analyze zeta potential data and predict stability."""

    def __init__(self, sample_name, iep=None):
        self.sample_name = sample_name
        self.iep = iep
        self.measurements = []

    def add_measurement(self, pH, zeta_mV, conductivity_mS=None):
        """Add a zeta potential measurement."""
        self.measurements.append({
            'pH': pH,
            'zeta': zeta_mV,
            'conductivity': conductivity_mS
        })

    def fit_sigmoidal(self):
        """Fit sigmoidal curve to pH titration data."""
        if len(self.measurements) < 3:
            return None

        pHs = np.array([m['pH'] for m in self.measurements])
        zetas = np.array([m['zeta'] for m in self.measurements])

        # Estimate IEP from sign change
        for i in range(len(zetas) - 1):
            if zetas[i] * zetas[i+1] < 0:
                # Linear interpolation
                self.iep = pHs[i] - zetas[i] * (pHs[i+1] - pHs[i]) / (zetas[i+1] - zetas[i])
                break

        return self.iep

    def stability_at_pH(self, pH):
        """Predict stability at given pH."""
        if not self.measurements:
            return None

        # Find closest measurements
        pHs = np.array([m['pH'] for m in self.measurements])
        zetas = np.array([m['zeta'] for m in self.measurements])

        # Interpolate
        zeta_interp = np.interp(pH, pHs, zetas)
        abs_zeta = abs(zeta_interp)

        if abs_zeta > 40:
            return {'rating': 'Excellent', 'score': 100, 'zeta': zeta_interp}
        elif abs_zeta > 30:
            return {'rating': 'Good', 'score': 80, 'zeta': zeta_interp}
        elif abs_zeta > 20:
            return {'rating': 'Moderate', 'score': 50, 'zeta': zeta_interp}
        else:
            return {'rating': 'Poor', 'score': 20, 'zeta': zeta_interp}

    def plot_titration(self, ax=None):
        """Plot pH titration curve."""
        if ax is None:
            fig, ax = plt.subplots(figsize=(10, 6))

        pHs = [m['pH'] for m in self.measurements]
        zetas = [m['zeta'] for m in self.measurements]

        ax.plot(pHs, zetas, 'o-', markersize=8, linewidth=2, label=self.sample_name)
        ax.axhline(y=0, color='black', linewidth=0.5)
        ax.axhline(y=30, color='green', linestyle='--', alpha=0.7)
        ax.axhline(y=-30, color='green', linestyle='--', alpha=0.7)

        if self.iep:
            ax.axvline(x=self.iep, color='red', linestyle=':', alpha=0.7)
            ax.text(self.iep + 0.1, max(zetas) * 0.9, f'IEP = {self.iep:.1f}', color='red')

        ax.fill_between(pHs, -30, 30, alpha=0.1, color='red')
        ax.set_xlabel('pH', fontsize=11)
        ax.set_ylabel('Zeta Potential (mV)', fontsize=11)
        ax.set_title(f'Zeta Potential Titration: {self.sample_name}', fontsize=12, fontweight='bold')
        ax.legend()
        ax.grid(True, alpha=0.3)

        return ax

# Simulate titration data for different materials
np.random.seed(42)

silica = ZetaPotentialAnalyzer('SiO₂ (20 nm)')
for pH in np.linspace(2, 10, 15):
    # SiO₂ IEP ≈ 2, strongly negative at higher pH
    zeta = -50 * (1 - np.exp(-(pH - 2) / 2)) + np.random.normal(0, 2)
    silica.add_measurement(pH, zeta)
silica.fit_sigmoidal()

titania = ZetaPotentialAnalyzer('TiO₂ (30 nm)')
for pH in np.linspace(2, 10, 15):
    # TiO₂ IEP ≈ 6
    zeta = 40 * np.tanh((6 - pH) * 0.8) + np.random.normal(0, 2)
    titania.add_measurement(pH, zeta)
titania.fit_sigmoidal()

alumina = ZetaPotentialAnalyzer('Al₂O₃ (50 nm)')
for pH in np.linspace(2, 12, 15):
    # Al₂O₃ IEP ≈ 9
    zeta = 50 * np.tanh((9 - pH) * 0.6) + np.random.normal(0, 2)
    alumina.add_measurement(pH, zeta)
alumina.fit_sigmoidal()

# Plot comparison
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Plot 1: All titration curves
ax1 = axes[0]
for sample in [silica, titania, alumina]:
    sample.plot_titration(ax1)
ax1.set_xlim(2, 12)
ax1.set_ylim(-70, 70)

# Plot 2: Stability windows
ax2 = axes[1]
pHs = np.linspace(2, 12, 100)

for sample, color in [(silica, 'blue'), (titania, 'green'), (alumina, 'red')]:
    scores = [sample.stability_at_pH(pH)['score'] for pH in pHs]
    ax2.plot(pHs, scores, linewidth=2, label=sample.sample_name, color=color)

ax2.axhline(y=80, color='gray', linestyle='--', alpha=0.7)
ax2.text(11, 82, 'Good stability', fontsize=9, color='gray')
ax2.set_xlabel('pH', fontsize=11)
ax2.set_ylabel('Stability Score', fontsize=11)
ax2.set_title('Predicted Stability vs pH', fontsize=12, fontweight='bold')
ax2.legend()
ax2.grid(True, alpha=0.3)
ax2.set_xlim(2, 12)
ax2.set_ylim(0, 110)

plt.tight_layout()
plt.savefig('zeta_potential.png', dpi=150, bbox_inches='tight')
plt.show()

# Print IEP values and recommended pH ranges
print("\n=== Zeta Potential Analysis Summary ===")
for sample in [silica, titania, alumina]:
    print(f"\n{sample.sample_name}:")
    print(f"  IEP = {sample.iep:.1f}")

    # Find stable pH ranges
    stable_ranges = []
    in_stable = False
    start_pH = None

    for pH in np.linspace(2, 12, 100):
        stab = sample.stability_at_pH(pH)
        if stab['score'] >= 80 and not in_stable:
            in_stable = True
            start_pH = pH
        elif stab['score'] < 80 and in_stable:
            in_stable = False
            stable_ranges.append(f"pH {start_pH:.1f}-{pH:.1f}")

    if in_stable:
        stable_ranges.append(f"pH {start_pH:.1f}-12.0")

    print(f"  Stable pH ranges: {', '.join(stable_ranges)}")
Example Output:
=== Zeta Potential Analysis Summary ===

SiO₂ (20 nm):
IEP = 2.0
Stable pH ranges: pH 4.5-12.0

TiO₂ (30 nm):
IEP = 6.0
Stable pH ranges: pH 2.0-4.0, pH 8.0-12.0

Al₂O₃ (50 nm):
IEP = 9.0
Stable pH ranges: pH 2.0-7.0

4.2 DLVO Theory

DLVO theory (Derjaguin-Landau-Verwey-Overbeek) describes colloidal stability as the balance between van der Waals attraction and electrostatic repulsion.

DLVO Total Interaction Energy

$$V_{total}(h) = V_{vdW}(h) + V_{elec}(h)$$

Van der Waals Attraction (equal spheres)

$$V_{vdW} = -\frac{A_H R}{12h}$$ (for h << R)

Electrostatic Repulsion (constant potential)

$$V_{elec} = 2\pi\varepsilon_r\varepsilon_0 R \psi_0^2 \ln[1 + \exp(-\kappa h)]$$

Debye Length

$$\kappa^{-1} = \sqrt{\frac{\varepsilon_r\varepsilon_0 k_B T}{2N_A e^2 I}}$$

Example 2: DLVO Interaction Energy Calculation

import numpy as np
import matplotlib.pyplot as plt

# ===================================
# Example 2: DLVO Theory Implementation
# ===================================

class DLVOCalculator:
    """Calculate DLVO interaction energy profiles."""

    # Physical constants
    eps_0 = 8.854e-12      # Vacuum permittivity (F/m)
    kB = 1.38e-23          # Boltzmann constant (J/K)
    e = 1.602e-19          # Elementary charge (C)
    NA = 6.022e23          # Avogadro's number

    def __init__(self, R_nm, A_H, psi_mV, eps_r=78.5, T=298):
        """
        Initialize DLVO calculator.

        Parameters:
            R_nm: Particle radius (nm)
            A_H: Hamaker constant (J)
            psi_mV: Surface potential (mV)
            eps_r: Relative permittivity
            T: Temperature (K)
        """
        self.R = R_nm * 1e-9
        self.A_H = A_H
        self.psi = psi_mV * 1e-3
        self.eps_r = eps_r
        self.T = T

    def debye_length(self, ionic_strength_M):
        """Calculate Debye length (m)."""
        I = ionic_strength_M * 1000  # mol/m³
        kappa_inv = np.sqrt(
            (self.eps_r * self.eps_0 * self.kB * self.T) /
            (2 * self.NA * self.e**2 * I)
        )
        return kappa_inv

    def v_vdw(self, h):
        """Van der Waals attraction (J)."""
        h = np.maximum(h, 1e-12)  # Avoid division by zero
        return -self.A_H * self.R / (12 * h)

    def v_elec(self, h, ionic_strength_M):
        """Electrostatic repulsion (J)."""
        h = np.maximum(h, 1e-12)
        kappa = 1 / self.debye_length(ionic_strength_M)

        V = (2 * np.pi * self.eps_r * self.eps_0 * self.R *
             self.psi**2 * np.log(1 + np.exp(-kappa * h)))
        return V

    def v_total(self, h, ionic_strength_M):
        """Total DLVO interaction (J)."""
        return self.v_vdw(h) + self.v_elec(h, ionic_strength_M)

    def energy_barrier(self, ionic_strength_M, h_range=None):
        """Find maximum energy barrier."""
        if h_range is None:
            h_range = np.linspace(0.3e-9, 50e-9, 1000)

        V = self.v_total(h_range, ionic_strength_M)
        V_kT = V / (self.kB * self.T)

        max_idx = np.argmax(V_kT)
        return {
            'barrier_kT': V_kT[max_idx],
            'distance_nm': h_range[max_idx] * 1e9,
            'stable': V_kT[max_idx] > 15
        }

    def plot_profile(self, ionic_strengths_M, ax=None):
        """Plot DLVO energy profiles."""
        if ax is None:
            fig, ax = plt.subplots(figsize=(10, 6))

        h_range = np.linspace(0.3e-9, 30e-9, 500)

        for I in ionic_strengths_M:
            V_total = self.v_total(h_range, I)
            V_kT = V_total / (self.kB * self.T)
            lambda_D = self.debye_length(I) * 1e9
            ax.plot(h_range * 1e9, V_kT, linewidth=2,
                    label=f'I = {I*1000:.0f} mM (λD = {lambda_D:.1f} nm)')

        ax.axhline(y=0, color='black', linewidth=0.5)
        ax.axhline(y=15, color='green', linestyle='--', alpha=0.7)
        ax.text(25, 17, 'Stability threshold (15 kT)', fontsize=9, color='green')

        ax.set_xlabel('Separation Distance (nm)', fontsize=11)
        ax.set_ylabel('Interaction Energy (kT)', fontsize=11)
        ax.set_title(f'DLVO Profile: R = {self.R*1e9:.0f} nm, ψ₀ = {self.psi*1000:.0f} mV',
                     fontsize=12, fontweight='bold')
        ax.legend()
        ax.grid(True, alpha=0.3)
        ax.set_xlim(0, 30)

        return ax

# Create DLVO calculator for silica nanoparticles
silica_dlvo = DLVOCalculator(
    R_nm=15,           # 30 nm diameter
    A_H=0.83e-20,      # SiO₂-SiO₂ in water
    psi_mV=-40         # Typical zeta potential at pH 7
)

# Plot effect of ionic strength
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

ax1 = axes[0]
ionic_strengths = [0.001, 0.01, 0.05, 0.1, 0.5]
silica_dlvo.plot_profile(ionic_strengths, ax1)
ax1.set_ylim(-50, 50)

# Plot 2: Stability diagram
ax2 = axes[1]
psi_range = np.linspace(10, 60, 50)
I_range = np.logspace(-3, 0, 50)
psi_mesh, I_mesh = np.meshgrid(psi_range, I_range)

barriers = np.zeros_like(psi_mesh)
for i in range(len(I_range)):
    for j in range(len(psi_range)):
        calc = DLVOCalculator(R_nm=15, A_H=0.83e-20, psi_mV=psi_mesh[i, j])
        result = calc.energy_barrier(I_mesh[i, j])
        barriers[i, j] = result['barrier_kT']

contour = ax2.contourf(psi_mesh, I_mesh * 1000, barriers, levels=20, cmap='RdYlGn')
ax2.contour(psi_mesh, I_mesh * 1000, barriers, levels=[15], colors='black', linewidths=2)
plt.colorbar(contour, ax=ax2, label='Energy Barrier (kT)')
ax2.set_yscale('log')
ax2.set_xlabel('Surface Potential |ψ₀| (mV)', fontsize=11)
ax2.set_ylabel('Ionic Strength (mM)', fontsize=11)
ax2.set_title('DLVO Stability Map (Barrier = 15 kT contour)', fontsize=12, fontweight='bold')

plt.tight_layout()
plt.savefig('dlvo_analysis.png', dpi=150, bbox_inches='tight')
plt.show()

# Print stability analysis
print("\n=== DLVO Stability Analysis (30 nm SiO₂, ψ = -40 mV) ===")
print(f"{'I (mM)':>10s} {'λD (nm)':>10s} {'Barrier (kT)':>15s} {'Stable?':>10s}")
print("-" * 50)
for I in ionic_strengths:
    result = silica_dlvo.energy_barrier(I)
    lambda_D = silica_dlvo.debye_length(I) * 1e9
    stable = "Yes" if result['stable'] else "No"
    print(f"{I*1000:>10.0f} {lambda_D:>10.1f} {result['barrier_kT']:>15.1f} {stable:>10s}")
Example Output:
=== DLVO Stability Analysis (30 nm SiO₂, ψ = -40 mV) ===
I (mM) λD (nm) Barrier (kT) Stable?
--------------------------------------------------
1 9.6 42.3 Yes
10 3.0 26.1 Yes
50 1.4 12.8 No
100 1.0 7.2 No
500 0.4 -2.1 No

Critical Coagulation Concentration

The ionic strength at which the energy barrier drops below ~15 kT is the Critical Coagulation Concentration (CCC). Above this concentration, particles aggregate rapidly. For the example above, CCC ≈ 50 mM for monovalent electrolytes.

4.3 Dynamic Light Scattering (DLS)

DLS measures particle size by analyzing fluctuations in scattered light intensity caused by Brownian motion.

Example 3: DLS Data Analysis

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import curve_fit

# ===================================
# Example 3: DLS Data Analysis
# ===================================

def stokes_einstein(D, T=298, eta=0.001):
    """
    Calculate hydrodynamic diameter from diffusion coefficient.

    Parameters:
        D: Diffusion coefficient (m²/s)
        T: Temperature (K)
        eta: Viscosity (Pa·s)

    Returns:
        Hydrodynamic diameter (nm)
    """
    kB = 1.38e-23
    d = kB * T / (3 * np.pi * eta * D)
    return d * 1e9  # Convert to nm

def lognormal_distribution(d, d_mean, sigma):
    """Log-normal particle size distribution."""
    return (1 / (d * sigma * np.sqrt(2 * np.pi))) * \
           np.exp(-(np.log(d) - np.log(d_mean))**2 / (2 * sigma**2))

def polydispersity_index(d_mean, d_std):
    """Calculate PDI from mean and standard deviation."""
    return (d_std / d_mean)**2

class DLSAnalyzer:
    """Analyze DLS measurement results."""

    def __init__(self, d_mean_nm, pdi, measurement_type='intensity'):
        """
        Initialize with measured values.

        Parameters:
            d_mean_nm: Z-average diameter (nm)
            pdi: Polydispersity index
            measurement_type: 'intensity', 'volume', or 'number'
        """
        self.d_z = d_mean_nm
        self.pdi = pdi
        self.sigma = np.sqrt(pdi)  # Approximate relationship

    def generate_distribution(self, d_range=None):
        """Generate size distribution."""
        if d_range is None:
            d_range = np.linspace(1, self.d_z * 3, 500)

        distribution = lognormal_distribution(d_range, self.d_z, max(0.1, self.sigma))
        distribution = distribution / np.trapz(distribution, d_range)

        return d_range, distribution

    def intensity_to_number(self, d_range, intensity_dist):
        """Convert intensity distribution to number distribution."""
        # Intensity ∝ d⁶ (Rayleigh scattering)
        number_dist = intensity_dist / d_range**6
        number_dist = number_dist / np.trapz(number_dist, d_range)
        return number_dist

    def quality_assessment(self):
        """Assess dispersion quality from DLS parameters."""
        if self.pdi < 0.1:
            quality = "Excellent (monodisperse)"
            score = 100
        elif self.pdi < 0.2:
            quality = "Good (narrow distribution)"
            score = 80
        elif self.pdi < 0.3:
            quality = "Moderate (polydisperse)"
            score = 60
        elif self.pdi < 0.5:
            quality = "Poor (broad distribution)"
            score = 40
        else:
            quality = "Very poor (aggregated?)"
            score = 20

        return {'quality': quality, 'score': score, 'pdi': self.pdi, 'd_z': self.d_z}

# Simulate DLS results for different samples
samples = {
    'Well-dispersed': {'d_z': 30, 'pdi': 0.08},
    'Moderately dispersed': {'d_z': 45, 'pdi': 0.25},
    'Partially aggregated': {'d_z': 150, 'pdi': 0.45},
    'Heavily aggregated': {'d_z': 500, 'pdi': 0.6}
}

fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# Plot 1: Size distributions (intensity-weighted)
ax1 = axes[0, 0]
for name, params in samples.items():
    analyzer = DLSAnalyzer(params['d_z'], params['pdi'])
    d, dist = analyzer.generate_distribution()
    ax1.plot(d, dist, linewidth=2, label=f"{name} (Z-avg={params['d_z']} nm)")

ax1.set_xlabel('Diameter (nm)', fontsize=11)
ax1.set_ylabel('Intensity (%)', fontsize=11)
ax1.set_title('Intensity-Weighted Size Distributions', fontsize=12, fontweight='bold')
ax1.legend(fontsize=9)
ax1.grid(True, alpha=0.3)
ax1.set_xlim(0, 300)

# Plot 2: Number vs Intensity distribution comparison
ax2 = axes[0, 1]
analyzer = DLSAnalyzer(50, 0.2)  # Example with moderate PDI
d, intensity_dist = analyzer.generate_distribution()
number_dist = analyzer.intensity_to_number(d, intensity_dist)

ax2.plot(d, intensity_dist / intensity_dist.max(), 'b-', linewidth=2, label='Intensity')
ax2.plot(d, number_dist / number_dist.max(), 'r--', linewidth=2, label='Number')
ax2.axvline(x=50, color='blue', linestyle=':', alpha=0.7)
ax2.axvline(x=d[np.argmax(number_dist)], color='red', linestyle=':', alpha=0.7)
ax2.set_xlabel('Diameter (nm)', fontsize=11)
ax2.set_ylabel('Normalized Intensity', fontsize=11)
ax2.set_title('Intensity vs Number Distribution', fontsize=12, fontweight='bold')
ax2.legend()
ax2.grid(True, alpha=0.3)
ax2.set_xlim(0, 150)

# Plot 3: Quality assessment
ax3 = axes[1, 0]
names = list(samples.keys())
pdi_values = [samples[n]['pdi'] for n in names]
colors = plt.cm.RdYlGn_r(np.array(pdi_values))

bars = ax3.bar(names, pdi_values, color=colors, edgecolor='black')
ax3.axhline(y=0.1, color='green', linestyle='--', alpha=0.7)
ax3.axhline(y=0.3, color='orange', linestyle='--', alpha=0.7)
ax3.axhline(y=0.5, color='red', linestyle='--', alpha=0.7)
ax3.text(3.5, 0.12, 'Monodisperse', fontsize=9, color='green')
ax3.text(3.5, 0.32, 'Acceptable', fontsize=9, color='orange')
ax3.text(3.5, 0.52, 'Aggregated', fontsize=9, color='red')
ax3.set_ylabel('Polydispersity Index (PDI)', fontsize=11)
ax3.set_title('Dispersion Quality Assessment', fontsize=12, fontweight='bold')
ax3.set_xticklabels(names, rotation=15, ha='right', fontsize=9)
ax3.grid(True, alpha=0.3, axis='y')

# Plot 4: Stability monitoring over time
ax4 = axes[1, 1]
times = [0, 1, 7, 14, 30]  # days
stable_sizes = [30, 31, 32, 33, 35]
unstable_sizes = [30, 45, 80, 150, 400]

ax4.semilogy(times, stable_sizes, 'go-', linewidth=2, markersize=8, label='Stable dispersion')
ax4.semilogy(times, unstable_sizes, 'ro-', linewidth=2, markersize=8, label='Unstable dispersion')
ax4.axhline(y=50, color='gray', linestyle='--', alpha=0.7)
ax4.text(25, 55, 'Aggregation threshold', fontsize=9, color='gray')
ax4.set_xlabel('Time (days)', fontsize=11)
ax4.set_ylabel('Z-Average Diameter (nm)', fontsize=11)
ax4.set_title('Stability Monitoring by DLS', fontsize=12, fontweight='bold')
ax4.legend()
ax4.grid(True, alpha=0.3, which='both')
ax4.set_xlim(0, 32)

plt.tight_layout()
plt.savefig('dls_analysis.png', dpi=150, bbox_inches='tight')
plt.show()

# Print quality assessment
print("\n=== DLS Quality Assessment ===")
for name, params in samples.items():
    analyzer = DLSAnalyzer(params['d_z'], params['pdi'])
    assessment = analyzer.quality_assessment()
    print(f"{name:25s}: Z-avg = {assessment['d_z']:>5.0f} nm, "
          f"PDI = {assessment['pdi']:.2f}, {assessment['quality']}")

4.4 Sedimentation Analysis

Sedimentation tests directly measure dispersion stability by monitoring particle settling over time.

Example 4: Stokes Sedimentation Model

import numpy as np
import matplotlib.pyplot as plt

# ===================================
# Example 4: Sedimentation Analysis
# ===================================

def stokes_settling_velocity(d_nm, rho_p, rho_f=1000, eta=0.001):
    """
    Calculate Stokes settling velocity.

    Parameters:
        d_nm: Particle diameter (nm)
        rho_p: Particle density (kg/m³)
        rho_f: Fluid density (kg/m³)
        eta: Fluid viscosity (Pa·s)

    Returns:
        Settling velocity (m/s)
    """
    d = d_nm * 1e-9
    g = 9.81
    delta_rho = rho_p - rho_f
    v = (delta_rho * g * d**2) / (18 * eta)
    return v

def settling_time(height_m, d_nm, rho_p, rho_f=1000, eta=0.001):
    """Calculate time for particle to settle given height."""
    v = stokes_settling_velocity(d_nm, rho_p, rho_f, eta)
    if v <= 0:  # Particle floats
        return np.inf
    return height_m / v

def brownian_diffusion_length(d_nm, t_s, T=298, eta=0.001):
    """
    Calculate characteristic Brownian diffusion length.

    Parameters:
        d_nm: Particle diameter (nm)
        t_s: Time (s)
        T: Temperature (K)
        eta: Viscosity (Pa·s)

    Returns:
        RMS displacement (m)
    """
    kB = 1.38e-23
    d = d_nm * 1e-9
    D = kB * T / (3 * np.pi * eta * d)
    return np.sqrt(2 * D * t_s)

# Compare settling for different particle sizes
d_range = np.logspace(0, 4, 100)  # 1 nm to 10 μm
rho_p = 2200  # kg/m³ (silica)
h = 0.05  # 5 cm settling height

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Plot 1: Settling velocity vs size
ax1 = axes[0]
v_settle = [stokes_settling_velocity(d, rho_p) for d in d_range]
ax1.loglog(d_range, np.array(v_settle) * 1e9, 'b-', linewidth=2.5, label='Settling velocity')

# Add Brownian velocity (characteristic velocity = sqrt(2D/t) where t = 1 hour)
t_ref = 3600  # 1 hour
v_brownian = [brownian_diffusion_length(d, t_ref) / t_ref for d in d_range]
ax1.loglog(d_range, np.array(v_brownian) * 1e9, 'r--', linewidth=2, label='Brownian velocity (1h)')

# Find crossover
crossover_idx = np.argmin(np.abs(np.array(v_settle) - np.array(v_brownian)))
ax1.axvline(x=d_range[crossover_idx], color='purple', linestyle=':', linewidth=2)
ax1.text(d_range[crossover_idx] * 1.2, 1e-2,
         f'Crossover ~{d_range[crossover_idx]:.0f} nm', fontsize=10, color='purple')

ax1.set_xlabel('Particle Diameter (nm)', fontsize=11)
ax1.set_ylabel('Velocity (nm/s)', fontsize=11)
ax1.set_title('Settling vs Brownian Motion', fontsize=12, fontweight='bold')
ax1.legend()
ax1.grid(True, alpha=0.3, which='both')
ax1.set_xlim(1, 10000)

# Plot 2: Settling time vs size
ax2 = axes[1]
t_settle = [settling_time(h, d, rho_p) for d in d_range]

ax2.loglog(d_range, np.array(t_settle) / 3600, 'b-', linewidth=2.5)  # Convert to hours

# Add time markers
ax2.axhline(y=1, color='green', linestyle='--', alpha=0.7)
ax2.axhline(y=24, color='orange', linestyle='--', alpha=0.7)
ax2.axhline(y=168, color='red', linestyle='--', alpha=0.7)
ax2.text(5000, 1.5, '1 hour', fontsize=9, color='green')
ax2.text(5000, 30, '1 day', fontsize=9, color='orange')
ax2.text(5000, 200, '1 week', fontsize=9, color='red')

ax2.set_xlabel('Particle Diameter (nm)', fontsize=11)
ax2.set_ylabel('Settling Time (hours)', fontsize=11)
ax2.set_title(f'Time to Settle 5 cm (SiO₂ in water)', fontsize=12, fontweight='bold')
ax2.grid(True, alpha=0.3, which='both')
ax2.set_xlim(1, 10000)
ax2.set_ylim(0.1, 1e6)

plt.tight_layout()
plt.savefig('sedimentation.png', dpi=150, bbox_inches='tight')
plt.show()

# Print settling times for key sizes
print("\n=== Sedimentation Analysis (5 cm height, SiO₂ in water) ===")
print(f"{'Diameter (nm)':>15s} {'Settling velocity':>20s} {'Time to settle':>20s}")
print("-" * 60)
for d in [10, 50, 100, 500, 1000, 5000]:
    v = stokes_settling_velocity(d, rho_p)
    t = settling_time(h, d, rho_p)
    if t < 3600:
        time_str = f"{t:.0f} s"
    elif t < 86400:
        time_str = f"{t/3600:.1f} hours"
    elif t < 86400 * 365:
        time_str = f"{t/86400:.1f} days"
    else:
        time_str = f"{t/(86400*365):.1f} years"
    print(f"{d:>15.0f} {v*1e9:>20.3f} nm/s {time_str:>20s}")
Example Output:
=== Sedimentation Analysis (5 cm height, SiO₂ in water) ===
Diameter (nm) Settling velocity Time to settle
------------------------------------------------------------
10 0.007 nm/s 231.5 years
50 0.163 nm/s 9.7 years
100 0.654 nm/s 2.4 years
500 16.350 nm/s 35.4 days
1000 65.400 nm/s 8.8 days
5000 1635.000 nm/s 8.5 hours

Key Insight

Nanoparticles smaller than ~100 nm are essentially stable against sedimentation (settling takes years). However, if they aggregate to form larger clusters (>500 nm), settling becomes significant within days to weeks.

4.5 Comprehensive Stability Assessment

Example 5: Multi-Parameter Stability Score

import numpy as np
import matplotlib.pyplot as plt

# ===================================
# Example 5: Comprehensive Stability Assessment
# ===================================

class StabilityAssessment:
    """Comprehensive stability evaluation combining multiple metrics."""

    def __init__(self, sample_name):
        self.name = sample_name
        self.metrics = {}

    def add_zeta_potential(self, zeta_mV):
        """Add zeta potential measurement."""
        abs_zeta = abs(zeta_mV)
        if abs_zeta > 40:
            score = 100
        elif abs_zeta > 30:
            score = 80
        elif abs_zeta > 20:
            score = 50
        else:
            score = 20

        self.metrics['zeta'] = {
            'value': zeta_mV,
            'score': score,
            'weight': 0.3
        }

    def add_dls(self, d_z_nm, pdi, d_target_nm=None):
        """Add DLS measurement."""
        # Size score (closer to target is better)
        if d_target_nm:
            size_ratio = d_z_nm / d_target_nm
            if size_ratio < 1.2:
                size_score = 100
            elif size_ratio < 1.5:
                size_score = 70
            elif size_ratio < 2:
                size_score = 40
            else:
                size_score = 10
        else:
            size_score = 100 if d_z_nm < 100 else (200 - d_z_nm) / 2

        # PDI score
        if pdi < 0.1:
            pdi_score = 100
        elif pdi < 0.2:
            pdi_score = 80
        elif pdi < 0.3:
            pdi_score = 60
        else:
            pdi_score = max(0, 100 - pdi * 100)

        combined_score = 0.5 * size_score + 0.5 * pdi_score

        self.metrics['dls'] = {
            'value': {'d_z': d_z_nm, 'pdi': pdi},
            'score': combined_score,
            'weight': 0.35
        }

    def add_dlvo_barrier(self, barrier_kT):
        """Add DLVO energy barrier."""
        if barrier_kT > 25:
            score = 100
        elif barrier_kT > 15:
            score = 80
        elif barrier_kT > 5:
            score = 40
        else:
            score = 10

        self.metrics['dlvo'] = {
            'value': barrier_kT,
            'score': score,
            'weight': 0.2
        }

    def add_sedimentation(self, settling_time_days):
        """Add sedimentation stability."""
        if settling_time_days > 365:
            score = 100
        elif settling_time_days > 90:
            score = 80
        elif settling_time_days > 30:
            score = 60
        elif settling_time_days > 7:
            score = 40
        else:
            score = 10

        self.metrics['sedimentation'] = {
            'value': settling_time_days,
            'score': score,
            'weight': 0.15
        }

    def overall_score(self):
        """Calculate weighted overall stability score."""
        total_weight = sum(m['weight'] for m in self.metrics.values())
        weighted_sum = sum(m['score'] * m['weight'] for m in self.metrics.values())
        return weighted_sum / total_weight

    def stability_rating(self):
        """Get stability rating."""
        score = self.overall_score()
        if score >= 85:
            return 'Excellent'
        elif score >= 70:
            return 'Good'
        elif score >= 50:
            return 'Moderate'
        else:
            return 'Poor'

    def report(self):
        """Print detailed stability report."""
        print(f"\n=== Stability Assessment: {self.name} ===")
        print("-" * 50)

        for metric_name, data in self.metrics.items():
            print(f"\n{metric_name.upper()}:")
            print(f"  Value: {data['value']}")
            print(f"  Score: {data['score']:.0f}/100")
            print(f"  Weight: {data['weight']*100:.0f}%")

        print("\n" + "=" * 50)
        print(f"OVERALL SCORE: {self.overall_score():.0f}/100")
        print(f"RATING: {self.stability_rating()}")
        print("=" * 50)

# Compare different samples
samples = []

# Sample 1: Well-optimized dispersion
s1 = StabilityAssessment("Optimized SiO₂")
s1.add_zeta_potential(-42)
s1.add_dls(35, 0.08, d_target_nm=30)
s1.add_dlvo_barrier(35)
s1.add_sedimentation(1000)
samples.append(s1)

# Sample 2: Moderate quality
s2 = StabilityAssessment("Moderate TiO₂")
s2.add_zeta_potential(-28)
s2.add_dls(80, 0.22, d_target_nm=50)
s2.add_dlvo_barrier(12)
s2.add_sedimentation(60)
samples.append(s2)

# Sample 3: Poor stability
s3 = StabilityAssessment("Unstable Fe₃O₄")
s3.add_zeta_potential(-15)
s3.add_dls(250, 0.45, d_target_nm=30)
s3.add_dlvo_barrier(3)
s3.add_sedimentation(5)
samples.append(s3)

# Print reports
for sample in samples:
    sample.report()

# Visualization
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Plot 1: Radar chart (simplified as bar chart)
ax1 = axes[0]
metric_names = ['Zeta\nPotential', 'DLS\nQuality', 'DLVO\nBarrier', 'Sedimentation']
x = np.arange(len(metric_names))
width = 0.25

for i, sample in enumerate(samples):
    scores = [
        sample.metrics['zeta']['score'],
        sample.metrics['dls']['score'],
        sample.metrics['dlvo']['score'],
        sample.metrics['sedimentation']['score']
    ]
    ax1.bar(x + i * width, scores, width, label=sample.name, alpha=0.8)

ax1.set_xticks(x + width)
ax1.set_xticklabels(metric_names)
ax1.set_ylabel('Score', fontsize=11)
ax1.set_title('Component Stability Scores', fontsize=12, fontweight='bold')
ax1.legend()
ax1.grid(True, alpha=0.3, axis='y')
ax1.set_ylim(0, 110)

# Plot 2: Overall comparison
ax2 = axes[1]
names = [s.name for s in samples]
overall_scores = [s.overall_score() for s in samples]
ratings = [s.stability_rating() for s in samples]
colors = ['green' if r == 'Excellent' else 'yellow' if r == 'Good'
          else 'orange' if r == 'Moderate' else 'red' for r in ratings]

bars = ax2.barh(names, overall_scores, color=colors, edgecolor='black', alpha=0.8)
ax2.axvline(x=85, color='green', linestyle='--', alpha=0.7)
ax2.axvline(x=70, color='orange', linestyle='--', alpha=0.7)
ax2.axvline(x=50, color='red', linestyle='--', alpha=0.7)

for bar, score, rating in zip(bars, overall_scores, ratings):
    ax2.text(bar.get_width() + 1, bar.get_y() + bar.get_height()/2,
             f'{score:.0f} ({rating})', va='center', fontsize=10)

ax2.set_xlabel('Overall Stability Score', fontsize=11)
ax2.set_title('Comprehensive Stability Comparison', fontsize=12, fontweight='bold')
ax2.set_xlim(0, 110)
ax2.grid(True, alpha=0.3, axis='x')

plt.tight_layout()
plt.savefig('comprehensive_stability.png', dpi=150, bbox_inches='tight')
plt.show()

Chapter Summary

Key Takeaways

  1. Zeta potential: |ζ| > 30 mV indicates good electrostatic stability
  2. DLVO theory: Energy barrier > 15 kT needed for colloidal stability
  3. DLS: PDI < 0.2 indicates narrow, well-dispersed samples
  4. Sedimentation: Particles < 100 nm are stable against settling
  5. Combined assessment: Use multiple techniques for reliable stability prediction

References

  1. Israelachvili, J. N. (2011). Intermolecular and Surface Forces (3rd ed.). Academic Press.
  2. Hunter, R. J. (2001). Foundations of Colloid Science (2nd ed.). Oxford University Press.
  3. Malvern Panalytical. "Dynamic Light Scattering: Common Terms Defined." Technical Note.