PYTHON · STEP-BY-STEP LESSON

Binary phase separation and periodic connectivity

I write a small part, check it, and then add the next part.

Download the notebook ↓ Open in Colab ↗

All Python notebook lessons →

The calculation, one step at a time

I show the purpose of each notebook cell beside its code and recorded result. The controls move through those results; they do not execute Python in the browser. The notebook remains available for running or changing the calculation.

Loading the lesson…

I compare mean compositions 0.5 and 0.4 using the same zero-mean perturbation. Composition c, energy coefficients, grid spacing and time are dimensionless. The 256² simulation uses dt=0.2, 2000 steps and seed 20260921. I measure four-neighbor connectivity at thresholds 0.45, 0.5 and 0.55. Mean composition and thresholded phase fraction are different quantities.

I develop this calculation in small steps. I run a cell, inspect its result and test it before adding the next part. Run the cells in order in Jupyter. A code cell defines a part of the calculation or carries it out; defining a function does not run it.

I permit free noncommercial teaching, learning and demonstrations with acknowledgment. Research and commercial use require my written permission. Earlier licensed releases retain their permissions. Read the code-use terms.

Copyright (c) 2026 Saswata Bhattacharyya.

Step 1: Prepare the Python environment

I install the packages listed in the next cell once in the same environment as the notebook. In Jupyter or Colab, remove the comment marker from the installation line below if a package is missing. NumPy stores arrays and evaluates numerical operations. Matplotlib displays plots. Pillow reads and writes images. This installation does not change the model parameters.

# %pip install numpy scikit-image matplotlib
import numpy as np
assert np.isfinite(np.array([0.0, 1.0])).all()
print("NumPy is ready:", np.__version__)
NumPy is ready: 2.1.3

Step 2: Input values

I retain the default values from the original calculation. The output name locates the files written by this notebook.

output = 'bicontinuity-results'

Step 3: Prepare the next part of the calculation

I define SEED, GRID_SIZE, GRID_SPACING. These are the same operations as in the original source, written on separate lines so their order is clear. Inspect their values before running the next cell.

from collections import deque
from pathlib import Path
import json
import sys
import numpy as np
from skimage.io import imsave
SEED = 20260921
GRID_SIZE = 256
GRID_SPACING = 1.0

Step 4: Prepare the next part of the calculation

I define TIME_STEP, NUMBER_OF_STEPS, A, MOBILITY, KAPPA, MEAN_COMPOSITIONS, THRESHOLDS. These are the same operations as in the original source, written on separate lines so their order is clear. Inspect their values before running the next cell.

TIME_STEP = 0.2
NUMBER_OF_STEPS = 2000
A = 1.0
MOBILITY = 1.0
KAPPA = 1.0
MEAN_COMPOSITIONS = (0.5, 0.4)
THRESHOLDS = (0.45, 0.5, 0.55)

Step 5: Visit connected pixels and record wrapping

I start at one occupied pixel and add its edge-sharing neighbors to a queue. Periodic indices wrap at the image boundary. Unwrapped positions continue beyond that boundary. Two visits to the same pixel with different unwrapped positions reveal a winding loop. Coordinate flags indicate wrapping along an axis; they do not measure the number of independent winding directions.

Read this complete operation (59 lines)
def connectivity(mask):
    """Count regions and detect loops winding around the periodic domain.

    A True pixel belongs to the region we are measuring. We start at an
    unvisited True pixel and visit every neighbor connected to it. This gives
    one component. Then we start again at the next unvisited True pixel.

    Array coordinates wrap at the boundaries. Alongside them, we track
    unwrapped coordinates, which continue beyond the box. If two paths reach
    the SAME pixel with different unwrapped coordinates, their difference
    reveals a loop winding around the periodic box. Simply joining across
    an edge is not enough: a small island straddling an edge need not wrap.
    """
    size_x, size_y = mask.shape
    visited = np.zeros(mask.shape, dtype=bool)
    unwrapped_x = np.zeros(mask.shape, dtype=int)
    unwrapped_y = np.zeros(mask.shape, dtype=int)
    components = []
    neighbors = ((1, 0), (-1, 0), (0, 1), (0, -1))
    for start_x, start_y in np.argwhere(mask):
        if visited[start_x, start_y]:
            continue
        visited[start_x, start_y] = True
        queue = deque([(start_x, start_y)])
        pixel_count = 0
        wraps_x = False
        wraps_y = False
        while queue:
            x, y = queue.popleft()
            pixel_count += 1
            for step_x, step_y in neighbors:
                next_x = (x + step_x) % size_x
                next_y = (y + step_y) % size_y
                if not mask[next_x, next_y]:
                    continue
                proposed_x = unwrapped_x[x, y] + step_x
                proposed_y = unwrapped_y[x, y] + step_y
                if not visited[next_x, next_y]:
                    visited[next_x, next_y] = True
                    unwrapped_x[next_x, next_y] = proposed_x
                    unwrapped_y[next_x, next_y] = proposed_y
                    queue.append((next_x, next_y))
                else:
                    if proposed_x != unwrapped_x[next_x, next_y]:
                        wraps_x = True
                    if proposed_y != unwrapped_y[next_x, next_y]:
                        wraps_y = True
        components.append({'pixels': pixel_count, 'wrap_x': wraps_x, 'wrap_y': wraps_y})
    region_pixels = int(mask.sum())
    largest_pixels = 0
    wrapping_components = []
    for component in components:
        largest_pixels = max(largest_pixels, component['pixels'])
        if component['wrap_x'] or component['wrap_y']:
            wrapping_components.append(component)
    largest_fraction = 0.0
    if region_pixels > 0:
        largest_fraction = largest_pixels / region_pixels
    return {'components': len(components), 'area_fraction': float(mask.mean()), 'largest_fraction_of_phase': largest_fraction, 'wrapping_components': wrapping_components}

Step 6: The evolution rule

I calculate the Fourier wave numbers and the squared magnitude k2. For portrait restoration, the error is the current field minus the fixed target. The Allen–Cahn error decays locally; the Cahn–Hilliard Fourier modes decay at rates proportional to k2. The spatial transform leaves the channel axis unchanged.

def evolve(composition):
    """Advance Cahn-Hilliard with a semi-implicit Fourier method.

    In Fourier space, Laplacian becomes -k^2. Treat df/dc explicitly and
    the gradient-energy contribution implicitly:

    c_hat(new) = [c_hat - dt M k^2 FFT(df/dc)] / [1 + dt M kappa k^4].

    The k=0 coefficient never changes, so the mean composition is conserved.
    The explicit nonlinear term still requires a suitably small time step.
    """
    wave_numbers = 2 * np.pi * np.fft.fftfreq(GRID_SIZE, d=GRID_SPACING)
    k_squared = wave_numbers[:, None] ** 2 + wave_numbers[None, :] ** 2
    denominator = 1 + TIME_STEP * MOBILITY * KAPPA * k_squared ** 2
    for iterator in range(1, NUMBER_OF_STEPS + 1):
        chemical_derivative = 2 * A * composition * (1 - composition) * (1 - 2 * composition)
        composition_hat = np.fft.fft2(composition)
        derivative_hat = np.fft.fft2(chemical_derivative)
        next_hat = (composition_hat - TIME_STEP * MOBILITY * k_squared * derivative_hat) / denominator
        composition = np.fft.ifft2(next_hat).real
        if iterator % 500 == 0:
            print(f'  Step {iterator}, time {iterator * TIME_STEP:.1f}')
    return composition

Step 7: Convert composition to display colors

I keep a fixed color scale. Clipping affects the displayed pixels only, not the simulated composition. The transpose and reversal match the coordinate orientation used on the website.

def save_image(composition, filename):
    """Save a fixed rainbow scale: blue=0, green=0.5, red=1.

    Clipping is only for display. The saved numerical field is not clipped.
    Transpose and flip so array axis 0 is horizontal, with y pointing up.
    """
    levels = [0, 0.25, 0.5, 0.75, 1]
    colors = np.array([[0, 0, 255], [0, 255, 255], [0, 255, 0], [255, 255, 0], [255, 0, 0]])
    display_values = np.clip(composition, 0, 1)
    rgb = np.zeros(composition.shape + (3,))
    for channel in range(3):
        rgb[:, :, channel] = np.interp(display_values, levels, colors[:, channel])
    image_pixels = rgb.astype('uint8').transpose(1, 0, 2)[::-1]
    imsave(filename, image_pixels)

Step 8: Prepare inputs and output locations

I name the input data and output directory explicitly. Re-running these cells replaces files in that lesson's output directory. I keep the original image or composition field separate from the evolving array.

output_folder = Path(output)
output_folder.mkdir(exist_ok=True, parents=True)
generator = np.random.default_rng(SEED)
perturbation = 0.01 * generator.standard_normal((GRID_SIZE, GRID_SIZE))
perturbation -= perturbation.mean()
results = {}

Step 9: Advance the field and record the result

I apply the update in its original order. I record the mean, energy or other diagnostics at the specified intervals. I do not clip the numerical field. A saved picture is a display of the field, not a substitute for a conservation check.

for mean in MEAN_COMPOSITIONS:
    print(f'Mean composition {mean}')
    composition = evolve(mean + perturbation)
    assert np.isfinite(composition).all(), 'Non-finite composition: check the time step.'
    assert abs(composition.mean() - mean) < 1e-10, 'Mean composition has drifted.'
    tag = f'{mean:.1f}'
    np.save(output_folder / f'composition-{tag}.npy', composition)
    save_image(composition, output_folder / f'composition-{tag}.png')
    threshold_results = {}
    for threshold in THRESHOLDS:
        threshold_results[str(threshold)] = {'high': connectivity(composition >= threshold), 'low': connectivity(composition < threshold)}
    results[tag] = {'mean': float(composition.mean()), 'thresholds': threshold_results}
Mean composition 0.5
  Step 500, time 100.0
  Step 1000, time 200.0
  Step 1500, time 300.0
  Step 2000, time 400.0
Mean composition 0.4
  Step 500, time 100.0
  Step 1000, time 200.0
  Step 1500, time 300.0
  Step 2000, time 400.0

Step 10: Prepare the next part of the calculation

I define report. These are the same operations as in the original source, written on separate lines so their order is clear. Inspect their values before running the next cell.

report = output_folder / 'connectivity.json'
report.write_text(json.dumps(results, indent=2))
print(f'Images, numerical fields and connectivity report saved in {output_folder}')
Images, numerical fields and connectivity report saved in bicontinuity-results

Plot and interpret the result

I display the original fixed color mapping. I examine the connected-component counts rather than conclude bicontinuity from an image alone. A conserved mean of 0.5 does not require both thresholded phases to have the same connectivity.

import matplotlib.pyplot as plt
from PIL import Image
fig, axes = plt.subplots(1, 2, figsize=(10, 4))
for ax, mean in zip(axes, MEAN_COMPOSITIONS):
    ax.imshow(Image.open(output_folder / f"composition-{mean:.1f}.png"))
    ax.set_title(f"Mean composition {mean}")
    ax.axis("off")
    print(mean, results[f"{mean:.1f}"]["thresholds"]["0.5"])
plt.show()
0.5 {'high': {'components': 16, 'area_fraction': 0.498565673828125, 'largest_fraction_of_phase': 0.568341800820224, 'wrapping_components': []}, 'low': {'components': 4, 'area_fraction': 0.501434326171875, 'largest_fraction_of_phase': 0.9728561864767817, 'wrapping_components': [{'pixels': 31970, 'wrap_x': True, 'wrap_y': True}]}}
0.4 {'high': {'components': 128, 'area_fraction': 0.37945556640625, 'largest_fraction_of_phase': 0.02871159723339231, 'wrapping_components': []}, 'low': {'components': 1, 'area_fraction': 0.62054443359375, 'largest_fraction_of_phase': 1.0, 'wrapping_components': [{'pixels': 40668, 'wrap_x': True, 'wrap_y': True}]}}
Computed result from the Binary phase separation and periodic connectivity notebook
Result from the executed notebook cell.

Step 11: Review and extend the calculation

I first restart the kernel and run all the required cells. Then I change one input, predict its effect and compare the result with that prediction. A successful check establishes the tested property; it does not establish convergence or equilibrium. I retain the complete script for command-line use. The notebook embeds the calculation directly and does not need that script to run.

Read the notebook execution checks → · More Python, Julia and C lessons →