import os
os.environ["SAS_OPENCL"] = "cuda" # use CUDA GPU backend for sasmodels
import escape as esc
import numpy as np
esc.require("0.9.8")
Loading material database from C:\dev\escape-core\python\src\escape\scattering\..\data\mdb\materials.db
SAXS. Form-factors. Core-shell bicelle (SasView-aligned)¶
A disc-like bicelle with three distinct SLD regions: a cylindrical core, flat face shells on both end caps, and a rim shell around the cylindrical wall.
Reference: https://www.sasview.org/docs/user/models/core_shell_bicelle.html
Parameters (SasView defaults)¶
| Parameter | Variable | Value |
|---|---|---|
| Scale | scale |
1 |
| Background (cm⁻¹) | background |
0.001 |
| Core radius (Å) | radius |
80 |
| Rim thickness (Å) | thick_rim |
10 |
| Face thickness (Å) | thick_face |
10 |
| Core length (Å) | length |
50 |
| Core SLD (10⁻⁶ Å⁻²) | sld_core |
1 |
| Face SLD (10⁻⁶ Å⁻²) | sld_face |
4 |
| Rim SLD (10⁻⁶ Å⁻²) | sld_rim |
4 |
| Solvent SLD (10⁻⁶ Å⁻²) | sld_solvent |
1 |
| Theta (deg), 2D only | theta |
90 |
| Phi (deg), 2D only | phi |
0 |
Form-factor (SasView core_shell_bicelle.c)¶
Three-term amplitude (core + face shell + rim shell):
$$F = (\rho_c-\rho_f)\,V_c\,\frac{2J_1(R\,q_\perp)}{R\,q_\perp}\,\mathrm{sinc}\!\left(\tfrac{L}{2}q_\parallel\right) + (\rho_f-\rho_r)\,V_{cf}\,\frac{2J_1(R\,q_\perp)}{R\,q_\perp}\,\mathrm{sinc}\!\left((\tfrac{L}{2}+t_f)q_\parallel\right) + (\rho_r-\rho_s)\,V_t\,\frac{2J_1((R+t_r)q_\perp)}{(R+t_r)q_\perp}\,\mathrm{sinc}\!\left((\tfrac{L}{2}+t_f)q_\parallel\right)$$
$$I(q) = \frac{\mathrm{scale}}{V_t}\int_0^{\pi/2} F^2\,\sin\alpha\,d\alpha + \mathrm{background}$$
# ── Variables ──────────────────────────────────────────────────────────────
q = esc.var("Q")
alpha = esc.var("alpha") # angle between disc normal and q
# ── Parameters ─────────────────────────────────────────────────────────────
scale = esc.par("Scale", 1.0, scale=1e8, fixed=True)
radius = esc.par("Radius", 80.0, units=esc.angstr)
thick_rim = esc.par("Thick rim", 10.0, units=esc.angstr)
thick_face = esc.par("Thick face", 10.0, units=esc.angstr)
length = esc.par("Length", 50.0, units=esc.angstr)
sld_core = esc.par("SLD core", 1.0, scale=1e-6, units=f"{esc.angstr}^-2")
sld_face = esc.par("SLD face", 4.0, scale=1e-6, units=f"{esc.angstr}^-2")
sld_rim = esc.par("SLD rim", 4.0, scale=1e-6, units=f"{esc.angstr}^-2")
sld_solvent = esc.par("SLD solvent", 1.0, scale=1e-6, units=f"{esc.angstr}^-2")
background = esc.par("Background", 0.001, userlim=[0.0, 0.03])
# ── Geometry ───────────────────────────────────────────────────────────────
r_rim = radius + thick_rim # outer radius including rim
h_core = 0.5 * length # core half-length
h_face = h_core + thick_face # half-length including face shells
v_core = np.pi * esc.pow(radius, 2) * length
v_cf = np.pi * esc.pow(radius, 2) * (length + 2.0 * thick_face) # core + face
v_tot = np.pi * esc.pow(r_rim, 2) * (length + 2.0 * thick_face) # total volume
# ── Oriented amplitude ─────────────────────────────────────────────────────
# q_axial = q * cos(alpha): component along disc normal
# q_radial = q * sin(alpha): component in disc plane
q_axial = q * esc.cos(alpha)
q_radial = q * esc.sin(alpha)
# Bessel factors for core/face radius and rim radius
bj_core = 2.0 * esc.j1_over_x(radius * q_radial)
bj_rim = 2.0 * esc.j1_over_x(r_rim * q_radial)
# Three-term amplitude: core, face shell, rim shell
f_core = (sld_core - sld_face) * v_core * bj_core * esc.sinc(h_core * q_axial)
f_face = (sld_face - sld_rim) * v_cf * bj_core * esc.sinc(h_face * q_axial)
f_rim = (sld_rim - sld_solvent) * v_tot * bj_rim * esc.sinc(h_face * q_axial)
f_tot = f_core + f_face + f_rim
# ── Powder average ─────────────────────────────────────────────────────────
i1d = (scale / v_tot
* esc.integral(esc.pow(f_tot, 2) * esc.sin(alpha),
alpha, 0.0, np.pi / 2.0,
numpoints=61, maxiter=5, epsabs=1e-5)
+ background)
i1d.device = "gpu"
qs = np.linspace(0.001, 0.8, 300)
i1d.show(coordinates=qs).config(
title="Core-shell bicelle — powder average (1D)",
xlog=True, ylog=True,
xlabel=f"Q [{esc.angstr}^-1]", ylabel="I(q) [cm^-1]")
2D oriented scattering (qx, qy)¶
For a fixed orientation $(\theta, \phi)$ the amplitude is evaluated directly at detector coordinates. The disc normal unit vector is $\hat{\mathbf{u}} = (\sin\theta\cos\phi,\;\sin\theta\sin\phi,\;\cos\theta)$.
$$I_{\mathrm{2D}}(q_x,q_y) = \frac{\mathrm{scale}}{V_t}\,F^2(q_\parallel, q_\perp) + \mathrm{background}$$
qx = esc.var("qx")
qy = esc.var("qy")
theta = esc.par("Theta", 90.0, userlim=[0.0, 180.0], units="deg")
phi = esc.par("Phi", 0.0, userlim=[0.0, 360.0], units="deg")
deg = np.pi / 180.0
sin_t = esc.sin(theta * deg)
ux = sin_t * esc.cos(phi * deg)
uy = sin_t * esc.sin(phi * deg)
q_sq = esc.pow(qx, 2) + esc.pow(qy, 2)
q_par_2d = qx * ux + qy * uy
q_perp_2d = esc.sqrt(q_sq - esc.pow(q_par_2d, 2))
bj_core_2d = 2.0 * esc.j1_over_x(radius * q_perp_2d)
bj_rim_2d = 2.0 * esc.j1_over_x(r_rim * q_perp_2d)
f_core_2d = (sld_core - sld_face) * v_core * bj_core_2d * esc.sinc(h_core * q_par_2d)
f_face_2d = (sld_face - sld_rim) * v_cf * bj_core_2d * esc.sinc(h_face * q_par_2d)
f_rim_2d = (sld_rim - sld_solvent) * v_tot * bj_rim_2d * esc.sinc(h_face * q_par_2d)
f_tot_2d = f_core_2d + f_face_2d + f_rim_2d
i2d = scale / v_tot * esc.pow(f_tot_2d, 2) + background
i2d.device = "gpu"
xs = np.linspace(-0.8, 0.8, 300); ys = np.linspace(-0.8, 0.8, 300)
xv, yv = np.meshgrid(xs, ys)
coords_2d = np.column_stack([xv.flatten(), yv.flatten()]).flatten()
i2d.show(coordinates=coords_2d).config(
title="Core-shell bicelle — oriented 2D SAXS (qx, qy)",
xlabel=f"qx [{esc.angstr}^-1]", ylabel=f"qy [{esc.angstr}^-1]",
cblog=True, colorscale="jet")
SasView reference model & comparison¶
| ESCAPE parameter | SasView parameter | Notes |
|---|---|---|
sld_core * 1e-6 |
sld_core |
core SLD (Å⁻²) |
sld_face * 1e-6 |
sld_face |
face shell SLD (Å⁻²) |
sld_rim * 1e-6 |
sld_rim |
rim shell SLD (Å⁻²) |
sld_solvent * 1e-6 |
sld_solvent |
solvent SLD (Å⁻²) |
radius |
radius |
core radius (Å) |
thick_rim |
thick_rim |
rim thickness (Å) |
thick_face |
thick_face |
face thickness (Å) |
length |
length |
core length (Å) |
import time
import matplotlib.pyplot as plt
from sasmodels.core import load_model
from sasmodels.data import empty_data1D
from sasmodels.direct_model import DirectModel
qs = np.linspace(0.001, 0.8, 300).copy()
kernel = load_model("core_shell_bicelle")
f_sas = DirectModel(empty_data1D(qs), kernel)
sas_pars = dict(scale=1.0, background=0.001,
sld_core=1.0, sld_face=4.0, sld_rim=4.0, sld_solvent=1.0,
radius=80.0, thick_rim=10.0, thick_face=10.0, length=50.0)
f_sas(**sas_pars)
i1d.device = "gpu"; i1d(qs[:5])
def timeit(fn, n=5):
t0 = time.perf_counter()
for _ in range(n): result = fn()
return (time.perf_counter() - t0) / n * 1e3, result
t_sas, Iq_sas = timeit(lambda: f_sas(**sas_pars))
i1d.device = "gpu"
t_gpu, Iq_gpu = timeit(lambda: i1d(qs), n=3)
i1d.device = "cpu"
t_cpu, Iq_cpu = timeit(lambda: i1d(qs))
i1d.device = "gpu"
print(f"SASView GPU : {t_sas:.0f} ms")
print(f"ESCAPE GPU : {t_gpu:.0f} ms")
print(f"ESCAPE CPU : {t_cpu:.0f} ms ({len(qs)} q-pts)")
rel = np.max(np.abs((Iq_gpu - Iq_sas) / Iq_sas)) * 100
print(f"Max relative diff vs SasView: {rel:.2f}%")
esc.overlay(Iq_sas, Iq_gpu, Iq_cpu, coordinates=qs).config(
xlabel="Q (1/A)", ylabel="I(q) (1/cm)",
xlog=True, ylog=True,
fig_title=f"Core shell bicelle I(q) — {len(qs)} pts",
labels=["SASView", "ESCAPE GPU", "ESCAPE CPU"],
line_styles=["solid", "dash", "dot"],
line_widths=[2, 3, 3]
)
SASView GPU : 10 ms ESCAPE GPU : 1 ms ESCAPE CPU : 11 ms (300 q-pts) Max relative diff vs SasView: 0.03%
C:\Users\User\AppData\Local\Temp\ipykernel_48812\965583263.py:16: UserWarning: Input array does not own its data (e.g. it is a view or slice); data will be copied