From ed2f739afd4ea3ef12c1c976df1b95a84b175a18 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 6 Aug 2026 10:37:27 -0400 Subject: [PATCH 01/10] Deprecate characteristic function camel case names --- .../srfit/pdf/characteristicfunctions.py | 425 ++++++++++++------ 1 file changed, 291 insertions(+), 134 deletions(-) diff --git a/src/diffpy/srfit/pdf/characteristicfunctions.py b/src/diffpy/srfit/pdf/characteristicfunctions.py index d1e239d1..34d6cee4 100644 --- a/src/diffpy/srfit/pdf/characteristicfunctions.py +++ b/src/diffpy/srfit/pdf/characteristicfunctions.py @@ -25,6 +25,12 @@ """ __all__ = [ + "spherical_particle", + "spheroidal_particle", + "lognormal_spherical_particle", + "sheet_particle", + "shell_particle", + "SASCF", "sphericalCF", "spheroidalCF", "spheroidalCF2", @@ -32,7 +38,6 @@ "sheetCF", "shellCF", "shellCF2", - "SASCF", ] import numpy @@ -43,88 +48,144 @@ from scipy.special import erf from diffpy.srfit.fitbase.calculator import Calculator +from diffpy.utils._deprecator import build_deprecation_message, deprecated +removal_version = "4.0.0" +cf_base = "diffpy.srfit.pdf.characteristicfunctions" -def sphericalCF(r, psize): - """Spherical nanoparticle characteristic function. +sphericalCF_dep_msg = build_deprecation_message( + cf_base, + "sphericalCF", + "spherical_particle", + removal_version, +) - Parameters - ---------- - r - distance of interaction - psize - The particle diameter +spheroidalCF_dep_msg = build_deprecation_message( + cf_base, + "spheroidalCF", + "spheroidal_particle", + removal_version, +) +spheroidalCF2_dep_msg = build_deprecation_message( + cf_base, + "spheroidalCF2", + "spheroidal_particle", + removal_version, +) - From Kodama et al., Acta Cryst. A, 62, 444-453 - (converted from radius to diameter) - """ - f = numpy.zeros(numpy.shape(r), dtype=float) - if psize > 0: - x = numpy.array(r, dtype=float) / psize - inside = x < 1.0 - xin = x[inside] - f[inside] = 1.0 - 1.5 * xin + 0.5 * xin * xin * xin - return f +lognormalSphericalCF_dep_msg = build_deprecation_message( + cf_base, + "lognormalSphericalCF", + "lognormal_spherical_particle", + removal_version, +) +sheetCF_dep_msg = build_deprecation_message( + cf_base, + "sheetCF", + "sheet_particle", + removal_version, +) -def spheroidalCF(r, erad, prad): - """Spheroidal characteristic function specified using radii. +shellCF_dep_msg = build_deprecation_message( + cf_base, + "shellCF", + "shell_particle", + removal_version, +) + +shellCF2_dep_msg = build_deprecation_message( + cf_base, + "shellCF2", + "shell_particle", + removal_version, +) - Spheroid with radii (erad, erad, prad) + +def spherical_particle(r, particle_diameter): + """Compute the spherical nanoparticle characteristic function. + + From Kodama et al., Acta Cryst. A, 62, 444-453 (converted from + radius to diameter). Parameters ---------- - prad - polar radius - erad - equatorial radius + r : array_like + The distance of interaction. + particle_diameter : float + The particle diameter. + + Returns + ------- + numpy.ndarray + The characteristic function values evaluated at `r`. + """ + characteristic_function = numpy.zeros(numpy.shape(r), dtype=float) + if particle_diameter > 0: + scaled_r = numpy.array(r, dtype=float) / particle_diameter + inside = scaled_r < 1.0 + scaled_r_inside = scaled_r[inside] + characteristic_function[inside] = ( + 1.0 - 1.5 * scaled_r_inside + 0.5 * scaled_r_inside**3 + ) + return characteristic_function + +@deprecated(sphericalCF_dep_msg) +def sphericalCF(r, psize): + """This function is deprecated and will be removed in version + 4.0.0. - erad < prad equates to a prolate spheroid - erad > prad equates to a oblate spheroid - erad == prad is a sphere + Please use + diffpy.srfit.pdf.characteristicfunctions.spherical_particle + instead. """ - psize = 2.0 * erad - pelpt = 1.0 * prad / erad - return spheroidalCF2(r, psize, pelpt) + return spherical_particle(r, psize) -def spheroidalCF2(r, psize, axrat): - """Spheroidal nanoparticle characteristic function. +def spheroidal_particle(r, equatorial_radius, polar_radius): + """Compute the spheroidal nanoparticle characteristic function. - Form factor for ellipsoid with radii (psize/2, psize/2, axrat*psize/2) + Spheroid with radii (equatorial_radius, equatorial_radius, + polar_radius). ``equatorial_radius < polar_radius`` equates to a + prolate spheroid, ``equatorial_radius > polar_radius`` equates to + an oblate spheroid, and ``equatorial_radius == polar_radius`` is a + sphere. From Lei et al., Phys. Rev. B, 80, 024118 (2009). Parameters ---------- - r - distance of interaction - psize - The equatorial diameter - axrat - The ratio of axis lengths - - - From Lei et al., Phys. Rev. B, 80, 024118 (2009) + r : array_like + The distance of interaction. + equatorial_radius : float + The equatorial radius. + polar_radius : float + The polar radius. + + Returns + ------- + numpy.ndarray + The characteristic function values evaluated at `r`. """ - pelpt = 1.0 * axrat + particle_diameter = 2.0 * equatorial_radius + axis_ratio = 1.0 * polar_radius / equatorial_radius - if psize <= 0 or pelpt <= 0: + if particle_diameter <= 0 or axis_ratio <= 0: return numpy.zeros_like(r) # to simplify the equations - v = pelpt - d = 1.0 * psize + v = axis_ratio + d = particle_diameter d2 = d * d v2 = v * v if v == 1: - return sphericalCF(r, psize) + return spherical_particle(r, particle_diameter) rx = r if v < 1: - r = rx[rx <= v * psize] + r = rx[rx <= v * d] r2 = r * r f1 = ( 1 @@ -138,7 +199,7 @@ def spheroidalCF2(r, psize, axrat): * atanh(sqrt(1 - v2)) ) - r = rx[numpy.logical_and(rx > v * psize, rx <= psize)] + r = rx[numpy.logical_and(rx > v * d, rx <= d)] r2 = r * r f2 = ( ( @@ -153,14 +214,14 @@ def spheroidalCF2(r, psize, axrat): / sqrt(1 - v2) ) - r = rx[rx > psize] + r = rx[rx > d] f3 = numpy.zeros_like(r) f = numpy.concatenate((f1, f2, f3)) elif v > 1: - r = rx[rx <= psize] + r = rx[rx <= d] r2 = r * r f1 = ( 1 @@ -174,7 +235,7 @@ def spheroidalCF2(r, psize, axrat): * atan(sqrt(v2 - 1)) ) - r = rx[numpy.logical_and(rx > psize, rx <= v * psize)] + r = rx[numpy.logical_and(rx > d, rx <= v * d)] r2 = r * r f2 = ( 1 @@ -194,7 +255,7 @@ def spheroidalCF2(r, psize, axrat): * (atan(sqrt(v2 - 1)) - atan(sqrt(r2 / d2 - 1))) ) - r = rx[rx > v * psize] + r = rx[rx > v * d] f3 = numpy.zeros_like(r) f = numpy.concatenate((f1, f2, f3)) @@ -202,46 +263,86 @@ def spheroidalCF2(r, psize, axrat): return f -def lognormalSphericalCF(r, psize, psig): - """Spherical nanoparticle characteristic function with lognormal - size distribution. +@deprecated(spheroidalCF_dep_msg) +def spheroidalCF(r, erad, prad): + """This function is deprecated and will be removed in version + 4.0.0. - Parameters - ---------- - r - distance of interaction - psize - The mean particle diameter - psig - The log-normal width of the particle diameter + Please use + diffpy.srfit.pdf.characteristicfunctions.spheroidal_particle + instead. + """ + return spheroidal_particle(r, erad, prad) - Here, r is the independent variable, mu is the mean of the distribution - (not of the particle size), and s is the width of the distribution. This is - the characteristic function for the lognormal distribution of particle - diameter: +@deprecated(spheroidalCF2_dep_msg) +def spheroidalCF2(r, psize, axrat): + """This function is deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.pdf.characteristicfunctions.spheroidal_particle + instead. + """ + equatorial_radius = 0.5 * psize + polar_radius = axrat * equatorial_radius + return spheroidal_particle(r, equatorial_radius, polar_radius) + + +def lognormal_spherical_particle( + r, particle_diameter, particle_diameter_sigma +): + """Compute the spherical nanoparticle characteristic function with + lognormal size distribution. + + Here, r is the independent variable, mu is the mean of the + distribution (not of the particle size), and s is the width of + the distribution. This is the characteristic function for the + lognormal distribution of particle diameter: F(r, mu, s) = 0.5*Erfc((-mu-3*s^2+Log(r))/(sqrt(2)*s)) + 0.25*r^3*Erfc((-mu+Log(r))/(sqrt(2)*s))*exp(-3*mu-4.5*s^2) - 0.75*r*Erfc((-mu-2*s^2+Log(r))/(sqrt(2)*s))*exp(-mu-2.5*s^2) The expectation value of the distribution gives the average particle - diameter, psize. The variance of the distribution gives psig^2. mu and s - can be expressed in terms of these as: + diameter, particle_diameter. The variance of the distribution gives + particle_diameter_sigma^2. mu and s can be expressed in terms of these + as: + + s^2 = log((particle_diameter_sigma/particle_diameter)^2 + 1) + mu = log(particle_diameter) - s^2/2 - s^2 = log((psig/psize)^2 + 1) - mu = log(psize) - s^2/2 + Source unknown. - Source unknown + Parameters + ---------- + r : array_like + The distance of interaction. + particle_diameter : float + The mean particle diameter. + particle_diameter_sigma : float + The log-normal width of the particle diameter. + + Returns + ------- + numpy.ndarray + The characteristic function values evaluated at `r`. """ - if psize <= 0: + if particle_diameter <= 0: return numpy.zeros_like(r) - if psig <= 0: - return sphericalCF(r, psize) + if particle_diameter_sigma <= 0: + return spherical_particle(r, particle_diameter) sqrt2 = sqrt(2.0) - s = sqrt(log(psig * psig / (1.0 * psize * psize) + 1)) - mu = log(psize) - s * s / 2 + s = sqrt( + log( + particle_diameter_sigma + * particle_diameter_sigma + / (1.0 * particle_diameter * particle_diameter) + + 1 + ) + ) + mu = log(particle_diameter) - s * s / 2 if mu < 0: return numpy.zeros_like(r) @@ -260,73 +361,92 @@ def lognormalSphericalCF(r, psize, psig): ) -def sheetCF(r, sthick): - """Nanosheet characteristic function. +@deprecated(lognormalSphericalCF_dep_msg) +def lognormalSphericalCF(r, psize, psig): + """This function is deprecated and will be removed in version + 4.0.0. - Parameters - ---------- - r - distance of interaction - sthick - Thickness of nanosheet + Please use + diffpy.srfit.pdf.characteristicfunctions.lognormal_spherical_particle + instead. + """ + return lognormal_spherical_particle(r, psize, psig) + + +def sheet_particle(r, sheet_thickness): + """Compute the nanosheet characteristic function. + From Kodama et al., Acta Cryst. A, 62, 444-453. - From Kodama et al., Acta Cryst. A, 62, 444-453 + Parameters + ---------- + r : array_like + The distance of interaction. + sheet_thickness : float + The thickness of the nanosheet. + + Returns + ------- + numpy.ndarray + The characteristic function values evaluated at `r`. """ - # handle zero or negative sthick. make it work for scalars and arrays. - if sthick <= 0: - return 0 * sthick + # handle zero or negative sheet_thickness. make it work for scalars and + # arrays. + if sheet_thickness <= 0: + return 0 * sheet_thickness # process scalar r if numpy.isscalar(r): - rv = 1 - 0.5 * r / sthick if r < sthick else 0.5 * sthick / r + rv = ( + 1 - 0.5 * r / sheet_thickness + if r < sheet_thickness + else 0.5 * sheet_thickness / r + ) return rv # handle array-type r - ra = numpy.asarray(r) - lo = ra < sthick - hi = ~lo - f = numpy.empty_like(ra, dtype=float) - f[lo] = 1 - 0.5 * ra[lo] / sthick - f[hi] = 0.5 * sthick / ra[hi] - return f - - -def shellCF(r, radius, thickness): - """Spherical shell characteristic function. - - Parameters - ---------- - radius - Inner radius - thickness - Thickness of shell + r_array = numpy.asarray(r) + inside = r_array < sheet_thickness + outside = ~inside + characteristic_function = numpy.empty_like(r_array, dtype=float) + characteristic_function[inside] = ( + 1 - 0.5 * r_array[inside] / sheet_thickness + ) + characteristic_function[outside] = 0.5 * sheet_thickness / r_array[outside] + return characteristic_function - outer radius = radius + thickness +@deprecated(sheetCF_dep_msg) +def sheetCF(r, sthick): + """This function is deprecated and will be removed in version + 4.0.0. - From Lei et al., Phys. Rev. B, 80, 024118 (2009) + Please use diffpy.srfit.pdf.characteristicfunctions.sheet_particle + instead. """ - d = 1.0 * thickness - a = 1.0 * radius + d / 2.0 - return shellCF2(r, a, d) + return sheet_particle(r, sthick) -def shellCF2(r, a, delta): - """Spherical shell characteristic function. +def shell_particle(r, radius, thickness): + """Compute the spherical shell characteristic function. + + The outer radius equals ``radius + thickness``. From Lei et al., + Phys. Rev. B, 80, 024118 (2009). Parameters ---------- - a - Central radius - delta - Thickness of shell - - outer radius = a + thickness/2 - - - From Lei et al., Phys. Rev. B, 80, 024118 (2009) + r : array_like + The distance of interaction. + radius : float + The inner radius. + thickness : float + The thickness of the shell. + + Returns + ------- + numpy.ndarray + The characteristic function values evaluated at `r`. """ - a = 1.0 * a - d = 1.0 * delta + d = 1.0 * thickness + a = 1.0 * radius + d / 2.0 a2 = a**2 d2 = d**2 dmr = d - r @@ -355,6 +475,30 @@ def shellCF2(r, a, delta): return f +@deprecated(shellCF_dep_msg) +def shellCF(r, radius, thickness): + """This function is deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.shell_particle + instead. + """ + return shell_particle(r, radius, thickness) + + +@deprecated(shellCF2_dep_msg) +def shellCF2(r, a, delta): + """This function is deprecated and will be removed in version + 4.0.0. + + Please use diffpy.srfit.pdf.characteristicfunctions.shell_particle + instead. + """ + radius = a - 0.5 * delta + thickness = delta + return shell_particle(r, radius, thickness) + + class SASCF(Calculator): """Calculator class for characteristic functions from sas-models. @@ -380,10 +524,10 @@ def __init__(self, name, model): Parameters ---------- - name - A name for the SASCF - model - SASModel object this adapts. + name : str + The name for the SASCF. + model : BaseModel + The sas.models.BaseModel object this adapts. """ Calculator.__init__(self, name) @@ -417,7 +561,8 @@ def __call__(self, r): # # The initial dr is somewhat arbitrary, but using dr = 0.01 allows for # the f(r) calculated from a particle of diameter 50, over r = - # arange(1, 60, 0.1) to agree with the sphericalCF with Rw < 1e-4%. + # arange(1, 60, 0.1) to agree with the spherical_particle with Rw < + # 1e-4%. # # We also have to make a q-spacing small enough to compute out to at # least the size of the signal. @@ -473,6 +618,18 @@ def __call__(self, r): def erfc(x): + """Compute the complementary error function. + + Parameters + ---------- + x : array_like + The input value. + + Returns + ------- + numpy.ndarray + The complementary error function evaluated at `x`. + """ return 1.0 - erf(x) From 8a816698648fd905ff3d2bf48cbedaa10af31ec4 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 6 Aug 2026 10:37:52 -0400 Subject: [PATCH 02/10] Change cf names in example scripts --- docs/examples/coreshellnp.py | 9 ++++++--- docs/examples/nppdfcrystal.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/examples/coreshellnp.py b/docs/examples/coreshellnp.py index fd54a50f..ba32415e 100644 --- a/docs/examples/coreshellnp.py +++ b/docs/examples/coreshellnp.py @@ -73,10 +73,13 @@ def makeRecipe(stru1, stru2, datname): # and a spherical shell CF for the shell. Since this is set up as two # phases, we implicitly assume that the core-shell correlations contribute # very little to the PDF. - from diffpy.srfit.pdf.characteristicfunctions import shellCF, sphericalCF + from diffpy.srfit.pdf.characteristicfunctions import ( + shell_particle, + spherical_particle, + ) - contribution.register_function(sphericalCF, name="f_CdS") - contribution.register_function(shellCF, name="f_ZnS") + contribution.register_function(spherical_particle, name="f_CdS") + contribution.register_function(shell_particle, name="f_ZnS") # Write the fitting equation. We want to sum the PDFs from each phase and # multiply it by a scaling factor. diff --git a/docs/examples/nppdfcrystal.py b/docs/examples/nppdfcrystal.py index 033a7162..62027fbd 100644 --- a/docs/examples/nppdfcrystal.py +++ b/docs/examples/nppdfcrystal.py @@ -56,9 +56,9 @@ def makeRecipe(ciffile, grdata): pdfcontribution.add_profile_generator(pdfgenerator) # Register the nanoparticle shape factor. - from diffpy.srfit.pdf.characteristicfunctions import sphericalCF + from diffpy.srfit.pdf.characteristicfunctions import spherical_particle - pdfcontribution.register_function(sphericalCF, name="f") + pdfcontribution.register_function(spherical_particle, name="f") # Now we set up the fitting equation. pdfcontribution.set_equation("f * G") From debbba64502804abb33b76f76ce1cf49b6ee7cef Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 6 Aug 2026 10:38:13 -0400 Subject: [PATCH 03/10] add tests for characteristic functions --- tests/test_characteristicfunctions.py | 320 +++++++++++++++++++++++++- 1 file changed, 316 insertions(+), 4 deletions(-) diff --git a/tests/test_characteristicfunctions.py b/tests/test_characteristicfunctions.py index 9b0d994d..e9eb1d14 100644 --- a/tests/test_characteristicfunctions.py +++ b/tests/test_characteristicfunctions.py @@ -12,14 +12,17 @@ # See LICENSE_DANSE.txt for license information. # ############################################################################## -"""Tests for sas package.""" +"""Tests for the nanoparticle characteristic functions and the SAS +calculator wrapper.""" # FIXME: remove this line when `docformatter` fixes the blank line bug +import re import unittest import numpy +import numpy.testing as npt import pytest try: @@ -56,7 +59,7 @@ def testSphere(sas_available): fr1 = ff(r) # Calculate sphere cf analytically - fr2 = cf.sphericalCF(r, 2 * radius) + fr2 = cf.spherical_particle(r, 2 * radius) diff = fr1 - fr2 res = numpy.dot(diff, diff) res /= numpy.dot(fr2, fr2) @@ -86,7 +89,7 @@ def testSpheroid(sas_available): fr1 = ff(r) # Calculate cf analytically - fr2 = cf.spheroidalCF(r, erad, prad) + fr2 = cf.spheroidal_particle(r, erad, prad) diff = fr1 - fr2 res = numpy.dot(diff, diff) res /= numpy.dot(fr2, fr2) @@ -116,7 +119,7 @@ def testShell(sas_available): fr1 = ff(r) # Calculate sphere cf analytically - fr2 = cf.shellCF(r, radius, thickness) + fr2 = cf.shell_particle(r, radius, thickness) diff = fr1 - fr2 res = numpy.dot(diff, diff) res /= numpy.dot(fr2, fr2) @@ -188,5 +191,314 @@ def testCylinder(sas_available): # End of class TestSASCF +# ---------------------------------------------------------------------------- +# The characteristic functions below compute the attenuation of the PDF due +# to a finite nanoparticle shape. Each has a known value of 1 at r = 0 (the +# particle always overlaps itself at zero distance) and drops to 0 once r +# reaches the largest distance spanned by the shape. + + +@pytest.mark.parametrize( + "input_r, input_particle_diameter, expected_characteristic_function", + [ + # C1: r ranges over values inside and outside the particle. + # Expected: the polynomial form 1 - 1.5x + 0.5x^3 (x = r / + # particle_diameter) applies inside the particle, and the function + # is zero at and beyond the particle diameter. + ( + numpy.array([0.0, 5.0, 10.0, 15.0]), + 10.0, + [1.0, 0.3125, 0.0, 0.0], + ), + # C2: the particle diameter is non-positive. + # Expected: the characteristic function is zero everywhere. + (numpy.array([0.0, 5.0]), 0.0, [0.0, 0.0]), + ], +) +def test_spherical_particle( + input_r, input_particle_diameter, expected_characteristic_function +): + actual_characteristic_function = cf.spherical_particle( + input_r, input_particle_diameter + ) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +@pytest.mark.parametrize( + "input_r, input_equatorial_radius, input_polar_radius, " + "expected_characteristic_function", + [ + # C1: equatorial_radius equals polar_radius, i.e. a sphere. + # Expected: matches spherical_particle for the equivalent diameter. + ( + numpy.linspace(0, 25, 6), + 10.0, + 10.0, + cf.spherical_particle(numpy.linspace(0, 25, 6), 20.0), + ), + # C2: polar_radius > equatorial_radius, i.e. a prolate spheroid. + # Expected: values from the Lei et al. characteristic function. + ( + numpy.array([0.0, 10.0, 20.0, 30.0, 40.0, 50.0]), + 10.0, + 20.0, + [ + 1.0, + 0.40106264900760513, + 0.054200238412168256, + 0.003650768214115807, + 0.0, + 0.0, + ], + ), + # C3: polar_radius < equatorial_radius, i.e. an oblate spheroid. + # Expected: values from the Lei et al. characteristic function. + ( + numpy.array([0.0, 5.0, 10.0, 15.0, 20.0, 25.0]), + 20.0, + 10.0, + [ + 1.0, + 0.744181556741916, + 0.5061470768546105, + 0.30368052370886184, + 0.15456586067544859, + 0.0675583474738014, + ], + ), + # C4: the polar radius is non-positive. + # Expected: the characteristic function is zero everywhere. + (numpy.array([1.0, 2.0]), 10.0, 0.0, [0.0, 0.0]), + ], +) +def test_spheroidal_particle( + input_r, + input_equatorial_radius, + input_polar_radius, + expected_characteristic_function, +): + actual_characteristic_function = cf.spheroidal_particle( + input_r, input_equatorial_radius, input_polar_radius + ) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +@pytest.mark.parametrize( + "input_r, input_particle_diameter, input_particle_diameter_sigma, " + "expected_characteristic_function", + [ + # C1: the lognormal distribution has no width. + # Expected: matches spherical_particle for the same diameter. + ( + numpy.array([0.0, 5.0, 10.0]), + 10.0, + 0.0, + cf.spherical_particle(numpy.array([0.0, 5.0, 10.0]), 10.0), + ), + # C2: a genuine lognormal size distribution is applied. + # Expected: values from the closed-form lognormal-averaged + # spherical characteristic function. + ( + numpy.array([1.0, 5.0, 10.0, 15.0]), + 10.0, + 2.0, + [ + 0.8617610662266728, + 0.362144891493816, + 0.039068792680198694, + 0.0009056141323687261, + ], + ), + # C3: the mean particle diameter is non-positive. + # Expected: the characteristic function is zero everywhere. + (numpy.array([1.0, 2.0]), 0.0, 1.0, [0.0, 0.0]), + ], +) +def test_lognormal_spherical_particle( + input_r, + input_particle_diameter, + input_particle_diameter_sigma, + expected_characteristic_function, +): + actual_characteristic_function = cf.lognormal_spherical_particle( + input_r, input_particle_diameter, input_particle_diameter_sigma + ) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +@pytest.mark.parametrize( + "input_r, input_sheet_thickness, expected_characteristic_function", + [ + # C1: r is an array with values inside and outside the sheet + # thickness. + # Expected: 1 - 0.5*r/thickness inside the sheet, and + # 0.5*thickness/r beyond it. + ( + numpy.array([0.0, 2.0, 4.0, 8.0]), + 4.0, + [1.0, 0.75, 0.5, 0.25], + ), + # C2: r is a scalar inside the sheet thickness. + # Expected: 1 - 0.5*r/thickness. + (2.0, 4.0, 0.75), + ], +) +def test_sheet_particle( + input_r, input_sheet_thickness, expected_characteristic_function +): + actual_characteristic_function = cf.sheet_particle( + input_r, input_sheet_thickness + ) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +@pytest.mark.parametrize( + "input_sheet_thickness", + [ + # C1: zero thickness. + # Expected: the characteristic function is zero, regardless of r. + 0.0, + # C2: negative thickness. + # Expected: the characteristic function is zero, regardless of r. + -1.0, + ], +) +def test_sheet_particle_non_positive_thickness(input_sheet_thickness): + actual_characteristic_function = cf.sheet_particle( + numpy.array([1.0, 2.0]), input_sheet_thickness + ) + expected_characteristic_function = 0 + assert actual_characteristic_function == expected_characteristic_function + + +@pytest.mark.parametrize( + "input_r, input_radius, input_thickness, " + "expected_characteristic_function", + [ + # C1: r ranges from the shell center out past the outer radius. + # Expected: values from the Lei et al. shell characteristic + # function; the function is 1 at r = 0 and 0 once r exceeds the + # outer radius (radius + thickness). + ( + numpy.array([0.0, 5.0, 12.5, 15.0, 20.0, 30.0]), + 10.0, + 5.0, + [ + 1.0, + 0.4934210526315789, + 0.19736842105263158, + 0.16447368421052633, + 0.12335526315789473, + 0.0, + ], + ), + ], +) +def test_shell_particle( + input_r, input_radius, input_thickness, expected_characteristic_function +): + actual_characteristic_function = cf.shell_particle( + input_r, input_radius, input_thickness + ) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +# ---------------------------------------------------------------------------- +# sphericalCF, spheroidalCF, spheroidalCF2, lognormalSphericalCF, sheetCF, +# shellCF, and shellCF2 are deprecated in favor of the snake_case functions +# above. Each old name must still work, emit a DeprecationWarning naming its +# replacement, and forward to the new implementation (translating arguments +# where the old and new parameterizations differ). + +module_path = "diffpy.srfit.pdf.characteristicfunctions" + + +@pytest.mark.parametrize( + "old_name, new_name, old_args, expected_characteristic_function", + [ + # C1: sphericalCF forwards directly to spherical_particle. + ( + "sphericalCF", + "spherical_particle", + (numpy.array([0.0, 5.0, 10.0]), 10.0), + cf.spherical_particle(numpy.array([0.0, 5.0, 10.0]), 10.0), + ), + # C2: spheroidalCF forwards directly to spheroidal_particle. + ( + "spheroidalCF", + "spheroidal_particle", + (numpy.array([0.0, 5.0, 10.0]), 10.0, 15.0), + cf.spheroidal_particle(numpy.array([0.0, 5.0, 10.0]), 10.0, 15.0), + ), + # C3: spheroidalCF2 used the raw (diameter, axis ratio) + # parameterization, which must be converted to (equatorial_radius, + # polar_radius) before forwarding to spheroidal_particle. + ( + "spheroidalCF2", + "spheroidal_particle", + (numpy.array([0.0, 5.0, 10.0]), 20.0, 1.5), + cf.spheroidal_particle(numpy.array([0.0, 5.0, 10.0]), 10.0, 15.0), + ), + # C4: lognormalSphericalCF forwards directly to + # lognormal_spherical_particle. + ( + "lognormalSphericalCF", + "lognormal_spherical_particle", + (numpy.array([1.0, 5.0, 10.0]), 10.0, 2.0), + cf.lognormal_spherical_particle( + numpy.array([1.0, 5.0, 10.0]), 10.0, 2.0 + ), + ), + # C5: sheetCF forwards directly to sheet_particle. + ( + "sheetCF", + "sheet_particle", + (numpy.array([0.0, 2.0, 4.0]), 4.0), + cf.sheet_particle(numpy.array([0.0, 2.0, 4.0]), 4.0), + ), + # C6: shellCF forwards directly to shell_particle. + ( + "shellCF", + "shell_particle", + (numpy.array([0.0, 5.0, 12.5]), 10.0, 5.0), + cf.shell_particle(numpy.array([0.0, 5.0, 12.5]), 10.0, 5.0), + ), + # C7: shellCF2 used the raw (central_radius, thickness) + # parameterization, which must be converted to (radius, thickness) + # before forwarding to shell_particle. + ( + "shellCF2", + "shell_particle", + (numpy.array([0.0, 5.0, 12.5]), 12.5, 5.0), + cf.shell_particle(numpy.array([0.0, 5.0, 12.5]), 10.0, 5.0), + ), + ], +) +def test_deprecated_functions_warn_and_forward( + old_name, new_name, old_args, expected_characteristic_function +): + old_function = getattr(cf, old_name) + expected_msg = ( + f"'{module_path}.{old_name}' is deprecated and will be removed " + f"in version 4.0.0. Please use '{module_path}.{new_name}' " + "instead." + ) + with pytest.warns(DeprecationWarning, match=re.escape(expected_msg)): + actual_characteristic_function = old_function(*old_args) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + if __name__ == "__main__": unittest.main() From e76eac586ecac51b489c20d48bd4ac54657913d3 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 6 Aug 2026 10:39:39 -0400 Subject: [PATCH 04/10] news --- news/characteristicfunctions-dep.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 news/characteristicfunctions-dep.rst diff --git a/news/characteristicfunctions-dep.rst b/news/characteristicfunctions-dep.rst new file mode 100644 index 00000000..ad756704 --- /dev/null +++ b/news/characteristicfunctions-dep.rst @@ -0,0 +1,23 @@ +**Added:** + +* Add ``spherical_particle``, ``spheroidal_particle``, ``lognormal_spherical_particle``, ``sheet_particle``, and ``shell_particle`` to ``diffpy.srfit.pdf.characteristicfunctions``, replacing ``sphericalCF``, ``spheroidalCF``, ``lognormalSphericalCF``, ``sheetCF``, and ``shellCF``. + +**Changed:** + +* Change ``spheroidalCF2`` and ``shellCF2`` in ``diffpy.srfit.pdf.characteristicfunctions`` to be deprecated aliases that convert their arguments and call ``spheroidal_particle`` and ``shell_particle``, instead of separate implementations. + +**Deprecated:** + +* Deprecate ``sphericalCF``, ``spheroidalCF``, ``spheroidalCF2``, ``lognormalSphericalCF``, ``sheetCF``, ``shellCF``, and ``shellCF2`` in ``diffpy.srfit.pdf.characteristicfunctions``. + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* From 11bcd526affc6b2c3f6931fe2d4b1febf680b746 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 6 Aug 2026 11:10:11 -0400 Subject: [PATCH 05/10] deprecate constrainAsSpaceGroup --- docs/examples/crystalpdf.py | 17 +++--- docs/examples/crystalpdfobjcryst.py | 2 +- docs/examples/simplepdf.py | 4 +- src/diffpy/__init__.py | 3 ++ src/diffpy/srfit/structure/__init__.py | 6 ++- src/diffpy/srfit/structure/sgconstraints.py | 57 ++++++++++++++++++--- tests/test_sgconstraints.py | 51 +++++++++++++++--- 7 files changed, 113 insertions(+), 27 deletions(-) diff --git a/docs/examples/crystalpdf.py b/docs/examples/crystalpdf.py index bb6ad3cf..842ef3c4 100644 --- a/docs/examples/crystalpdf.py +++ b/docs/examples/crystalpdf.py @@ -93,16 +93,17 @@ def makeRecipe(ciffile, datname): # We start by constraining the phase to the known space group. We could do # this by hand, but there is a method in diffpy.srfit.structure named - # 'constrainAsSpaceGroup' for this purpose. The constraints will by default - # be applied to the sites, the lattice and to the ADPs. See the method - # documentation for more details. The 'constrainAsSpaceGroup' method may - # create new Parameters, which it returns in a SpaceGroupParameters object. - from diffpy.srfit.structure import constrainAsSpaceGroup + # 'constrain_as_space_group' for this purpose. The constraints will by + # default be applied to the sites, the lattice and to the ADPs. See the + # method documentation for more details. The 'constrain_as_space_group' + # method may create new Parameters, which it returns in a + # SpaceGroupParameters object. + from diffpy.srfit.structure import constrain_as_space_group - sgpars = constrainAsSpaceGroup(phase, "Fm-3m") + sgpars = constrain_as_space_group(phase, "Fm-3m") - # The SpaceGroupParameters object returned by 'constrainAsSpaceGroup' holds - # the free Parameters allowed by the space group constraints. Once a + # The SpaceGroupParameters object returned by 'constrain_as_space_group' + # holds the free Parameters allowed by the space group constraints. Once a # structure is constrained, we need (should) only use the Parameters # provided in the SpaceGroupParameters, as the relevant structure # Parameters are constrained to these. diff --git a/docs/examples/crystalpdfobjcryst.py b/docs/examples/crystalpdfobjcryst.py index 0d427d04..6922ef55 100644 --- a/docs/examples/crystalpdfobjcryst.py +++ b/docs/examples/crystalpdfobjcryst.py @@ -84,7 +84,7 @@ def makeRecipe(ciffile, datname): # constraints get enforced within the ObjCrystCrystalParSet. Free # Parameters are stored within the 'sgpars' member of the # ObjCrystCrystalParSet, which is the same as the object returned from - # 'constrainAsSpaceGroup'. + # 'constrain_as_space_group'. # # As before, we have one free lattice parameter ('a'). We can simplify # things by iterating through all the sgpars. diff --git a/docs/examples/simplepdf.py b/docs/examples/simplepdf.py index 0611dc08..7b200cc0 100644 --- a/docs/examples/simplepdf.py +++ b/docs/examples/simplepdf.py @@ -48,9 +48,9 @@ def makeRecipe(ciffile, datname): # Configure the fit variables phase = contribution.nickel.phase - from diffpy.srfit.structure import constrainAsSpaceGroup + from diffpy.srfit.structure import constrain_as_space_group - sgpars = constrainAsSpaceGroup(phase, "Fm-3m") + sgpars = constrain_as_space_group(phase, "Fm-3m") for par in sgpars.latpars: recipe.add_variable(par) diff --git a/src/diffpy/__init__.py b/src/diffpy/__init__.py index 75681401..dc504884 100644 --- a/src/diffpy/__init__.py +++ b/src/diffpy/__init__.py @@ -15,3 +15,6 @@ # See LICENSE.rst for license information. # ############################################################################## +from pkgutil import extend_path + +__path__ = extend_path(__path__, __name__) diff --git a/src/diffpy/srfit/structure/__init__.py b/src/diffpy/srfit/structure/__init__.py index d09aa5cc..0990c76f 100644 --- a/src/diffpy/srfit/structure/__init__.py +++ b/src/diffpy/srfit/structure/__init__.py @@ -16,7 +16,10 @@ ParameterSet interface and automatic structure constraint generation from space group information.""" -from diffpy.srfit.structure.sgconstraints import constrainAsSpaceGroup +from diffpy.srfit.structure.sgconstraints import ( + constrain_as_space_group, + constrainAsSpaceGroup, +) def struToParameterSet(name, stru): @@ -58,6 +61,7 @@ def struToParameterSet(name, stru): # silence pyflakes checker +assert constrain_as_space_group assert constrainAsSpaceGroup diff --git a/src/diffpy/srfit/structure/sgconstraints.py b/src/diffpy/srfit/structure/sgconstraints.py index 92cdde7b..2fdd48cb 100644 --- a/src/diffpy/srfit/structure/sgconstraints.py +++ b/src/diffpy/srfit/structure/sgconstraints.py @@ -20,11 +20,22 @@ from diffpy.srfit.fitbase.parameter import ParameterProxy from diffpy.srfit.fitbase.recipeorganizer import RecipeContainer +from diffpy.utils._deprecator import build_deprecation_message, deprecated -__all__ = ["constrainAsSpaceGroup"] +__all__ = ["constrain_as_space_group", "constrainAsSpaceGroup"] +removal_version = "4.0.0" +sgconstraints_base = "diffpy.srfit.structure.sgconstraints" -def constrainAsSpaceGroup( +constrainAsSpaceGroup_dep_msg = build_deprecation_message( + sgconstraints_base, + "constrainAsSpaceGroup", + "constrain_as_space_group", + removal_version, +) + + +def constrain_as_space_group( phase, spacegroup, scatterers=None, @@ -120,6 +131,36 @@ def constrainAsSpaceGroup( return sgp +@deprecated(constrainAsSpaceGroup_dep_msg) +def constrainAsSpaceGroup( + phase, + spacegroup, + scatterers=None, + sgoffset=[0, 0, 0], + constrainlat=True, + constrainadps=True, + adpsymbols=None, + isosymbol="Uiso", +): + """This function is deprecated and will be removed in version + 4.0.0. + + Please use + diffpy.srfit.structure.sgconstraints.constrain_as_space_group + instead. + """ + return constrain_as_space_group( + phase, + spacegroup, + scatterers, + sgoffset, + constrainlat, + constrainadps, + adpsymbols, + isosymbol, + ) + + def _constrain_as_space_group( phase, sg, @@ -130,10 +171,10 @@ def _constrain_as_space_group( adpsymbols=None, isosymbol="Uiso", ): - """Restricted interface to constrainAsSpaceGroup. + """Restricted interface to constrain_as_space_group. - Arguments: As constrainAsSpaceGroup, except - ------------------------------------------- + Arguments: As constrain_as_space_group, except + ----------------------------------------------- sg diffpy.structure.spacegroups.SpaceGroup instance """ @@ -158,7 +199,7 @@ def _constrain_as_space_group( return sgp -# End constrainAsSpaceGroup +# End constrain_as_space_group class BaseSpaceGroupParameters(RecipeContainer): @@ -210,7 +251,7 @@ class SpaceGroupParameters(BaseSpaceGroupParameters): This class is used to store the variable Parameters of a structure, leaving out those that constrained or fixed due to space group. This does the work - of the constrainAsSpaceGroup method. This class has the same Parameter + of the constrain_as_space_group method. This class has the same Parameter attribute access of a ParameterSet. Attributes @@ -352,7 +393,7 @@ def _get_adp_pars(self): def _make_constraints(self): """Constrain the structure to the space group. - This works as described by the constrainAsSpaceGroup method. + This works as described by the constrain_as_space_group method. """ # Start by clearing the constraints self._clear_constraints() diff --git a/tests/test_sgconstraints.py b/tests/test_sgconstraints.py index 211d61be..40319d76 100644 --- a/tests/test_sgconstraints.py +++ b/tests/test_sgconstraints.py @@ -14,6 +14,7 @@ ############################################################################## """Tests space group constraints.""" +import re import unittest import numpy @@ -106,17 +107,17 @@ def test_ObjCryst_constrain_space_group(pyobjcryst_available): def test_DiffPy_constrain_as_space_group(datafile, pyobjcryst_available): - """Test the constrainAsSpaceGroup function.""" + """Test the constrain_as_space_group function.""" if not pyobjcryst_available: pytest.skip("pyobjcrysta package not available") from diffpy.srfit.structure.diffpyparset import DiffpyStructureParSet - from diffpy.srfit.structure.sgconstraints import constrainAsSpaceGroup + from diffpy.srfit.structure.sgconstraints import constrain_as_space_group stru = makeLaMnO3_P1(datafile) parset = DiffpyStructureParSet("LaMnO3", stru) - sgpars = constrainAsSpaceGroup( + sgpars = constrain_as_space_group( parset, "P b n m", scatterers=parset.getScatterers()[::2], @@ -179,27 +180,63 @@ def _alltests(par): def test_constrain_as_space_group_args(pyobjcryst_available, datafile): - """Test the arguments processing of constrainAsSpaceGroup + """Test the arguments processing of constrain_as_space_group function.""" if not pyobjcryst_available: pytest.skip("pyobjcrysta package not available") from diffpy.srfit.structure.diffpyparset import DiffpyStructureParSet - from diffpy.srfit.structure.sgconstraints import constrainAsSpaceGroup + from diffpy.srfit.structure.sgconstraints import constrain_as_space_group from diffpy.structure.spacegroups import GetSpaceGroup stru = makeLaMnO3_P1(datafile) parset = DiffpyStructureParSet("LaMnO3", stru) - sgpars = constrainAsSpaceGroup(parset, "P b n m") + sgpars = constrain_as_space_group(parset, "P b n m") sg = GetSpaceGroup("P b n m") parset2 = DiffpyStructureParSet("LMO", makeLaMnO3_P1(datafile)) - sgpars2 = constrainAsSpaceGroup(parset2, sg) + sgpars2 = constrain_as_space_group(parset2, sg) list(sgpars) list(sgpars2) assert sgpars.names == sgpars2.names return +# ---------------------------------------------------------------------------- +# constrainAsSpaceGroup is deprecated in favor of constrain_as_space_group. +# The old name must still work, emit a DeprecationWarning naming its +# replacement, and forward to the new implementation. + + +def test_constrainAsSpaceGroup_warns_and_forwards( + pyobjcryst_available, datafile +): + if not pyobjcryst_available: + pytest.skip("pyobjcrysta package not available") + + from diffpy.srfit.structure.diffpyparset import DiffpyStructureParSet + from diffpy.srfit.structure.sgconstraints import ( + constrain_as_space_group, + constrainAsSpaceGroup, + ) + + module_path = "diffpy.srfit.structure.sgconstraints" + expected_msg = ( + f"'{module_path}.constrainAsSpaceGroup' is deprecated and will be " + f"removed in version 4.0.0. Please use " + f"'{module_path}.constrain_as_space_group' instead." + ) + + parset = DiffpyStructureParSet("LaMnO3", makeLaMnO3_P1(datafile)) + with pytest.warns(DeprecationWarning, match=re.escape(expected_msg)): + actual_sgpars = constrainAsSpaceGroup(parset, "P b n m") + + parset2 = DiffpyStructureParSet("LMO", makeLaMnO3_P1(datafile)) + expected_sgpars = constrain_as_space_group(parset2, "P b n m") + + assert actual_sgpars.names == expected_sgpars.names + return + + def makeLaMnO3_P1(datafile): from diffpy.structure import Structure From 506d19a3a5db0d172c9da2c82709595f03f88862 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 6 Aug 2026 11:10:20 -0400 Subject: [PATCH 06/10] update news --- news/characteristicfunctions-dep.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/news/characteristicfunctions-dep.rst b/news/characteristicfunctions-dep.rst index ad756704..fe5e7f2c 100644 --- a/news/characteristicfunctions-dep.rst +++ b/news/characteristicfunctions-dep.rst @@ -1,6 +1,7 @@ **Added:** * Add ``spherical_particle``, ``spheroidal_particle``, ``lognormal_spherical_particle``, ``sheet_particle``, and ``shell_particle`` to ``diffpy.srfit.pdf.characteristicfunctions``, replacing ``sphericalCF``, ``spheroidalCF``, ``lognormalSphericalCF``, ``sheetCF``, and ``shellCF``. +* Add ``constrain_as_space_group`` to ``diffpy.srfit.structure.sgconstraints``, replacing ``constrainAsSpaceGroup``. **Changed:** @@ -9,6 +10,7 @@ **Deprecated:** * Deprecate ``sphericalCF``, ``spheroidalCF``, ``spheroidalCF2``, ``lognormalSphericalCF``, ``sheetCF``, ``shellCF``, and ``shellCF2`` in ``diffpy.srfit.pdf.characteristicfunctions``. +* Deprecate ``constrainAsSpaceGroup`` in ``diffpy.srfit.structure.sgconstraints``. **Removed:** From d63573d18796d7cd492a39386a52db5a14f03720 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 6 Aug 2026 15:27:44 -0400 Subject: [PATCH 07/10] change: emit a warning if a characteristic function goes to a negative value during refinement. The output goes to zero --- .../srfit/pdf/characteristicfunctions.py | 137 ++++++++++++++++-- 1 file changed, 122 insertions(+), 15 deletions(-) diff --git a/src/diffpy/srfit/pdf/characteristicfunctions.py b/src/diffpy/srfit/pdf/characteristicfunctions.py index 34d6cee4..742e5ae7 100644 --- a/src/diffpy/srfit/pdf/characteristicfunctions.py +++ b/src/diffpy/srfit/pdf/characteristicfunctions.py @@ -40,6 +40,8 @@ "shellCF2", ] +import warnings + import numpy from numpy import arctan as atan from numpy import arctanh as atanh @@ -103,6 +105,36 @@ ) +# The record of non-physical input warnings already issued in this process, +# keyed by (function name, requirement). See _warn_non_physical. +_warned_non_physical = set() + + +def _warn_non_physical(function_name, requirement): + """Warn that non-physical input produced a zero characteristic + function. + + A refinement evaluates a characteristic function on every iteration, + so the warning is issued only once per process for each distinct + requirement. The message must not include the offending value, since + the `warnings` registry keys on the message text and a value that + drifts from iteration to iteration would defeat the suppression. + """ + key = (function_name, requirement) + if key in _warned_non_physical: + return + _warned_non_physical.add(key) + warnings.warn( + f"In '{function_name}', {requirement}. The characteristic function " + "was set to zero for every r, which flattens the fit residual so a " + "refinement cannot recover on its own. Please use a physically " + "meaningful starting value, or keep the parameter in range with " + "FitRecipe.add_soft_bounds.", + RuntimeWarning, + stacklevel=3, + ) + + def spherical_particle(r, particle_diameter): """Compute the spherical nanoparticle characteristic function. @@ -120,15 +152,26 @@ def spherical_particle(r, particle_diameter): ------- numpy.ndarray The characteristic function values evaluated at `r`. + + Warns + ----- + RuntimeWarning + If `particle_diameter` is not positive, in which case the + characteristic function is zero everywhere. The warning is issued + only once per process. """ characteristic_function = numpy.zeros(numpy.shape(r), dtype=float) - if particle_diameter > 0: - scaled_r = numpy.array(r, dtype=float) / particle_diameter - inside = scaled_r < 1.0 - scaled_r_inside = scaled_r[inside] - characteristic_function[inside] = ( - 1.0 - 1.5 * scaled_r_inside + 0.5 * scaled_r_inside**3 + if particle_diameter <= 0: + _warn_non_physical( + "spherical_particle", "'particle_diameter' must be positive" ) + return characteristic_function + scaled_r = numpy.array(r, dtype=float) / particle_diameter + inside = scaled_r < 1.0 + scaled_r_inside = scaled_r[inside] + characteristic_function[inside] = ( + 1.0 - 1.5 * scaled_r_inside + 0.5 * scaled_r_inside**3 + ) return characteristic_function @@ -166,13 +209,28 @@ def spheroidal_particle(r, equatorial_radius, polar_radius): ------- numpy.ndarray The characteristic function values evaluated at `r`. + + Warns + ----- + RuntimeWarning + If `equatorial_radius` or `polar_radius` is not positive, in which + case the characteristic function is zero everywhere. The warning is + issued only once per process. """ + if equatorial_radius <= 0: + _warn_non_physical( + "spheroidal_particle", "'equatorial_radius' must be positive" + ) + return numpy.zeros(numpy.shape(r), dtype=float) + if polar_radius <= 0: + _warn_non_physical( + "spheroidal_particle", "'polar_radius' must be positive" + ) + return numpy.zeros(numpy.shape(r), dtype=float) + particle_diameter = 2.0 * equatorial_radius axis_ratio = 1.0 * polar_radius / equatorial_radius - if particle_diameter <= 0 or axis_ratio <= 0: - return numpy.zeros_like(r) - # to simplify the equations v = axis_ratio d = particle_diameter @@ -327,10 +385,30 @@ def lognormal_spherical_particle( ------- numpy.ndarray The characteristic function values evaluated at `r`. + + Warns + ----- + RuntimeWarning + If `particle_diameter` is not positive, if `particle_diameter_sigma` + is negative, or if `particle_diameter` is too small for + `particle_diameter_sigma` for the closed form to hold, in which case + the characteristic function is zero everywhere. The warning is issued + only once per process. A `particle_diameter_sigma` of zero is the + sphere limit rather than an error and does not warn. """ if particle_diameter <= 0: - return numpy.zeros_like(r) - if particle_diameter_sigma <= 0: + _warn_non_physical( + "lognormal_spherical_particle", + "'particle_diameter' must be positive", + ) + return numpy.zeros(numpy.shape(r), dtype=float) + if particle_diameter_sigma < 0: + _warn_non_physical( + "lognormal_spherical_particle", + "'particle_diameter_sigma' must not be negative", + ) + return numpy.zeros(numpy.shape(r), dtype=float) + if particle_diameter_sigma == 0: return spherical_particle(r, particle_diameter) sqrt2 = sqrt(2.0) @@ -344,7 +422,11 @@ def lognormal_spherical_particle( ) mu = log(particle_diameter) - s * s / 2 if mu < 0: - return numpy.zeros_like(r) + _warn_non_physical( + "lognormal_spherical_particle", + "'particle_diameter' is too small for 'particle_diameter_sigma'", + ) + return numpy.zeros(numpy.shape(r), dtype=float) return ( 0.5 * erfc((-mu - 3 * s * s + log(r)) / (sqrt2 * s)) @@ -389,11 +471,21 @@ def sheet_particle(r, sheet_thickness): ------- numpy.ndarray The characteristic function values evaluated at `r`. + + Warns + ----- + RuntimeWarning + If `sheet_thickness` is not positive, in which case the + characteristic function is zero everywhere. The warning is issued + only once per process. """ - # handle zero or negative sheet_thickness. make it work for scalars and - # arrays. if sheet_thickness <= 0: - return 0 * sheet_thickness + _warn_non_physical( + "sheet_particle", "'sheet_thickness' must be positive" + ) + if numpy.isscalar(r): + return 0.0 + return numpy.zeros(numpy.shape(r), dtype=float) # process scalar r if numpy.isscalar(r): rv = ( @@ -444,7 +536,22 @@ def shell_particle(r, radius, thickness): ------- numpy.ndarray The characteristic function values evaluated at `r`. + + Warns + ----- + RuntimeWarning + If `thickness` is not positive or `radius` is negative, in which case + the characteristic function is zero everywhere. The warning is issued + only once per process. A `radius` of zero is the solid sphere limit + rather than an error and does not warn. """ + if thickness <= 0: + _warn_non_physical("shell_particle", "'thickness' must be positive") + return numpy.zeros(numpy.shape(r), dtype=float) + if radius < 0: + _warn_non_physical("shell_particle", "'radius' must not be negative") + return numpy.zeros(numpy.shape(r), dtype=float) + d = 1.0 * thickness a = 1.0 * radius + d / 2.0 a2 = a**2 From 27dc7caec8c6810e057f7314d84b2467442c70da Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 6 Aug 2026 15:28:19 -0400 Subject: [PATCH 08/10] Add tests for new CF behavior for non-physical input parameters --- tests/conftest.py | 16 ++ tests/test_characteristicfunctions.py | 234 +++++++++++++++++++++++--- 2 files changed, 222 insertions(+), 28 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2f40a218..b0859d0d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -437,3 +437,19 @@ def parser_datafiles(tmp_path): ) yield tmp_path + + +@pytest.fixture +def reset_characteristic_function_warnings(): + """Clear the record of already-issued non-physical input warnings. + + The characteristic functions warn only once per process for each + distinct non-physical input so that a refinement loop does not flood + the user with repeated messages. Tests that assert on the warning + must start and finish with a clean record. + """ + from diffpy.srfit.pdf.characteristicfunctions import _warned_non_physical + + _warned_non_physical.clear() + yield + _warned_non_physical.clear() diff --git a/tests/test_characteristicfunctions.py b/tests/test_characteristicfunctions.py index e9eb1d14..d40068a1 100644 --- a/tests/test_characteristicfunctions.py +++ b/tests/test_characteristicfunctions.py @@ -20,6 +20,7 @@ import re import unittest +import warnings import numpy import numpy.testing as npt @@ -210,9 +211,6 @@ def testCylinder(sas_available): 10.0, [1.0, 0.3125, 0.0, 0.0], ), - # C2: the particle diameter is non-positive. - # Expected: the characteristic function is zero everywhere. - (numpy.array([0.0, 5.0]), 0.0, [0.0, 0.0]), ], ) def test_spherical_particle( @@ -268,9 +266,6 @@ def test_spherical_particle( 0.0675583474738014, ], ), - # C4: the polar radius is non-positive. - # Expected: the characteristic function is zero everywhere. - (numpy.array([1.0, 2.0]), 10.0, 0.0, [0.0, 0.0]), ], ) def test_spheroidal_particle( @@ -313,9 +308,6 @@ def test_spheroidal_particle( 0.0009056141323687261, ], ), - # C3: the mean particle diameter is non-positive. - # Expected: the characteristic function is zero everywhere. - (numpy.array([1.0, 2.0]), 0.0, 1.0, [0.0, 0.0]), ], ) def test_lognormal_spherical_particle( @@ -360,25 +352,6 @@ def test_sheet_particle( ) -@pytest.mark.parametrize( - "input_sheet_thickness", - [ - # C1: zero thickness. - # Expected: the characteristic function is zero, regardless of r. - 0.0, - # C2: negative thickness. - # Expected: the characteristic function is zero, regardless of r. - -1.0, - ], -) -def test_sheet_particle_non_positive_thickness(input_sheet_thickness): - actual_characteristic_function = cf.sheet_particle( - numpy.array([1.0, 2.0]), input_sheet_thickness - ) - expected_characteristic_function = 0 - assert actual_characteristic_function == expected_characteristic_function - - @pytest.mark.parametrize( "input_r, input_radius, input_thickness, " "expected_characteristic_function", @@ -413,6 +386,211 @@ def test_shell_particle( ) +# ---------------------------------------------------------------------------- +# A refinement can step a shape parameter into a region that has no physical +# meaning, such as a negative diameter or thickness. The characteristic +# functions must not raise there, because that would abort a refinement that +# would otherwise recover on its own, so they return zero for every r instead. +# A zero return is flat in the shape parameter and so stalls the optimizer, +# which the user cannot see from the fit output alone; each function therefore +# also warns. The warning is issued only once per process for each distinct +# problem so that a refinement loop does not bury the user in repeated +# messages. +# +# Every such warning states which requirement was violated and then closes +# with the same explanation of the consequence and of how to avoid it. Only +# the requirement varies between cases. + + +@pytest.mark.parametrize( + "function_name, input_args, expected_requirement", + [ + # C1: the sphere diameter is zero. + # Expected: zero everywhere, warning that the diameter must be + # positive. + ( + "spherical_particle", + (numpy.array([0.0, 5.0]), 0.0), + "'particle_diameter' must be positive", + ), + # C2: the sphere diameter is negative. + # Expected: zero everywhere, warning that the diameter must be + # positive. + ( + "spherical_particle", + (numpy.array([1.0, 5.0, 10.0]), -10.0), + "'particle_diameter' must be positive", + ), + # C3: the spheroid equatorial radius is zero, which the axis ratio + # would otherwise be divided by. + # Expected: zero everywhere, warning that the equatorial radius must + # be positive, and no ZeroDivisionError. + ( + "spheroidal_particle", + (numpy.array([1.0, 5.0]), 0.0, 5.0), + "'equatorial_radius' must be positive", + ), + # C4: the spheroid polar radius is negative. + # Expected: zero everywhere, warning that the polar radius must be + # positive. + ( + "spheroidal_particle", + (numpy.array([1.0, 5.0]), 10.0, -5.0), + "'polar_radius' must be positive", + ), + # C5: the mean particle diameter of the lognormal distribution is + # zero. + # Expected: zero everywhere, warning that the diameter must be + # positive. + ( + "lognormal_spherical_particle", + (numpy.array([1.0, 2.0]), 0.0, 1.0), + "'particle_diameter' must be positive", + ), + # C6: the width of the lognormal distribution is negative. A negative + # width is meaningless rather than a limiting case, so it must not be + # quietly treated as a distribution of zero width. + # Expected: zero everywhere, warning that the width must not be + # negative. + ( + "lognormal_spherical_particle", + (numpy.array([1.0, 5.0, 10.0]), 10.0, -2.0), + "'particle_diameter_sigma' must not be negative", + ), + # C7: the mean particle diameter is too small for the requested + # distribution width, which puts the underlying lognormal mean below + # zero and invalidates the closed form. + # Expected: zero everywhere, warning that the diameter is too small + # for the width. + ( + "lognormal_spherical_particle", + (numpy.array([1.0, 5.0]), 2.0, 4.0), + "'particle_diameter' is too small for 'particle_diameter_sigma'", + ), + # C8: the sheet thickness is zero. + # Expected: zero everywhere, warning that the thickness must be + # positive. + ( + "sheet_particle", + (numpy.array([1.0, 2.0]), 0.0), + "'sheet_thickness' must be positive", + ), + # C9: the sheet thickness is negative. + # Expected: zero everywhere, warning that the thickness must be + # positive. + ( + "sheet_particle", + (numpy.array([1.0, 2.0]), -1.0), + "'sheet_thickness' must be positive", + ), + # C10: the shell thickness is zero, which would otherwise make the + # normalizing denominator vanish and return an unattenuated one at + # every r. + # Expected: zero everywhere, warning that the thickness must be + # positive. + ( + "shell_particle", + (numpy.array([1.0, 5.0, 10.0]), 10.0, 0.0), + "'thickness' must be positive", + ), + # C11: the shell thickness is negative, which would otherwise return + # a smoothly varying branch with values below negative one that an + # optimizer can mistake for a real solution. + # Expected: zero everywhere, warning that the thickness must be + # positive. + ( + "shell_particle", + (numpy.array([1.0, 5.0, 10.0]), 10.0, -5.0), + "'thickness' must be positive", + ), + # C12: the shell inner radius is negative. + # Expected: zero everywhere, warning that the radius must not be + # negative. + ( + "shell_particle", + (numpy.array([1.0, 5.0, 10.0]), -20.0, 5.0), + "'radius' must not be negative", + ), + ], +) +def test_non_physical_input_returns_zero_and_warns( + function_name, + input_args, + expected_requirement, + reset_characteristic_function_warnings, +): + function = getattr(cf, function_name) + input_r = input_args[0] + expected_remedy = ( + "The characteristic function was set to zero for every r, which " + "flattens the fit residual so a refinement cannot recover on its own. " + "Please use a physically meaningful starting value, or keep the " + "parameter in range with FitRecipe.add_soft_bounds." + ) + expected_msg = ( + f"In '{function_name}', {expected_requirement}. {expected_remedy}" + ) + with pytest.warns(RuntimeWarning, match=re.escape(expected_msg)): + actual_characteristic_function = function(*input_args) + npt.assert_allclose( + actual_characteristic_function, numpy.zeros_like(input_r) + ) + + +@pytest.mark.parametrize( + "function_name, input_args, expected_characteristic_function", + [ + # C1: a lognormal distribution of zero width is the sphere limit + # rather than a mistake. + # Expected: matches spherical_particle for the same diameter, with no + # warning. + ( + "lognormal_spherical_particle", + (numpy.array([1.0, 5.0, 10.0]), 10.0, 0.0), + cf.spherical_particle(numpy.array([1.0, 5.0, 10.0]), 10.0), + ), + # C2: a shell of zero inner radius is a solid sphere rather than a + # mistake. + # Expected: matches spherical_particle for twice the thickness, with + # no warning. + ( + "shell_particle", + (numpy.array([1.0, 5.0, 10.0]), 0.0, 5.0), + cf.spherical_particle(numpy.array([1.0, 5.0, 10.0]), 10.0), + ), + ], +) +def test_limiting_input_does_not_warn( + function_name, + input_args, + expected_characteristic_function, + reset_characteristic_function_warnings, +): + function = getattr(cf, function_name) + with warnings.catch_warnings(): + warnings.simplefilter("error") + actual_characteristic_function = function(*input_args) + npt.assert_allclose( + actual_characteristic_function, expected_characteristic_function + ) + + +def test_non_physical_input_warns_only_once( + reset_characteristic_function_warnings, +): + # A refinement evaluates the characteristic function on every iteration, + # so a function that warns on each call would flood the user. The + # suppression must not rely on the warnings registry, which callers can + # disable, so this asserts under the "always" filter. + with warnings.catch_warnings(record=True) as raised_warnings: + warnings.simplefilter("always") + for _ in range(5): + cf.spherical_particle(numpy.array([1.0, 2.0]), -10.0) + actual_warning_count = len(raised_warnings) + expected_warning_count = 1 + assert actual_warning_count == expected_warning_count + + # ---------------------------------------------------------------------------- # sphericalCF, spheroidalCF, spheroidalCF2, lognormalSphericalCF, sheetCF, # shellCF, and shellCF2 are deprecated in favor of the snake_case functions From 723ffa1990704a73cd18ced55fe22d72b49ac228 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Thu, 6 Aug 2026 15:28:32 -0400 Subject: [PATCH 09/10] cf news --- news/cf-non-physical-input.rst | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 news/cf-non-physical-input.rst diff --git a/news/cf-non-physical-input.rst b/news/cf-non-physical-input.rst new file mode 100644 index 00000000..85eb1004 --- /dev/null +++ b/news/cf-non-physical-input.rst @@ -0,0 +1,27 @@ +**Added:** + +* + +**Changed:** + +* Change the characteristic functions in ``diffpy.srfit.pdf.characteristicfunctions`` to emit a ``RuntimeWarning`` when a non-physical shape parameter makes them return zero, since a zero return flattens the fit residual and stalls a refinement without any other sign to the user. The warning is issued once per process for each distinct problem so a refinement loop does not repeat it. +* Change ``lognormal_spherical_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` to return zero for a negative ``particle_diameter_sigma`` instead of silently returning ``spherical_particle``. A ``particle_diameter_sigma`` of zero is still the sphere limit. +* Change ``sheet_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` to return an array of zeros for a non-positive ``sheet_thickness`` when ``r`` is an array, instead of a scalar zero. + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* Fix ``shell_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` returning a smoothly varying branch of unphysical values, some below negative one, for a negative ``thickness`` or ``radius``, which a refinement could mistake for a real solution. It now returns zero. +* Fix ``shell_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` returning one at every ``r`` for a zero ``thickness``, where the normalizing denominator vanishes. It now returns zero. +* Fix ``spheroidal_particle`` in ``diffpy.srfit.pdf.characteristicfunctions`` raising ``ZeroDivisionError`` for a zero ``equatorial_radius``. It now returns zero. + +**Security:** + +* From 56163672585b127f3a058f7588a8a832dfaedbc0 Mon Sep 17 00:00:00 2001 From: Caden Myers Date: Fri, 7 Aug 2026 08:51:08 -0400 Subject: [PATCH 10/10] empty commit for CI