Skip to content

Commit 6eed7ef

Browse files
author
Paul Kienzle
committed
compute granddaughter buildup for 2-stage decay changes
1 parent 83b643f commit 6eed7ef

2 files changed

Lines changed: 93 additions & 32 deletions

File tree

periodictable/activation.py

Lines changed: 91 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -340,42 +340,81 @@ def decay_time(self, target: float, tol: float=1e-10):
340340
if not self.rest_times or not self.activity:
341341
return 0
342342

343-
# Find the smallest rest time (probably 0 hr)
344-
k, t_k = min(enumerate(self.rest_times), key=lambda x: x[1])
345-
# Find the activity at that time, and the decay rate
346-
data = [
347-
(Ia[k], LN2/a.Thalf_hrs)
348-
for a, Ia in self.activity.items()
349-
# TODO: not sure why Ia is zero, but it messes up the initial value guess if it is there
350-
if Ia[k] > 0.0
351-
]
343+
# TODO: save intensity at zero separate from self.rest_times
344+
345+
# Find I(0) for each isotope given I(t) at the first rest time.
346+
# Use Bateman equation to compute backward for granddaughter products:
347+
# Id(t) = Id(0)exp(-λd t) + Ip(0)(exp(-λp t) - exp(-λd t))/(1 - λp/λd)
348+
# Data is [(I(0), λ, reaction), ...] for each active isotope
349+
t = self.rest_times[0]
350+
data: list[tuple[float, float, str]] = []
351+
352352
# Need an initial guess near the answer otherwise find_root gets confused.
353353
# Small but significant activation with an extremely long half-life will
354354
# dominate at long times, but at short times they will not affect the
355-
# derivative. Choosing a time that satisfies the longest half-life seems
355+
# derivative. Choosing a time that satisfies the longest decay time seems
356356
# to work well enough.
357-
guess = max(-log(target/Ia)/La + t_k for Ia, La in data)
358-
# With times far from zero the time resolution in the exponential is
359-
# poor. Adjust the start time to the initial guess, rescaling intensities
360-
# to the activity at that time.
361-
adj = [(Ia*exp(-La*(guess-t_k)), La) for Ia, La in data]
362-
#print(adj)
357+
t_guess = 0.
358+
359+
for a, Ia in self.activity.items():
360+
intensity = Ia[0]
361+
La = LN2/a.Thalf_hrs
362+
# Estimate activity at t=0, with a check that it hasn't decayed to zero.
363+
# The check is necessary because exp(λt) will exceed the floating point
364+
# number range when I(t) = I(0)exp(-λt) = 0.
365+
if t > 0.:
366+
# print(f"{a.isotope} {a.daughter} {a.Thalf_hrs=} {La=}; {intensity=} at {t=}")
367+
intensity = intensity * exp(La*t) if intensity > 0. else 0.
368+
if a.reaction == "b":
369+
Ip, Lp, _ = data[-1]
370+
#print(f"{intensity=} {Ip=} {Lp=} {Ia[0]=} {La=}")
371+
intensity -= Ip*(expm1((La-Lp)*t)/(1 - Lp/La)) if Ip > 0. else 0.
372+
data.append((intensity, La, a.reaction))
373+
374+
# Daughter intensity will be transformed to granddaughter intensity
375+
# over time. Assume it is instantaneous at t=0 for the purpose of guessing
376+
# the decay time of the granddaughter. The root finder will correct for
377+
# the discrepency.
378+
I_buildup = data[-1][0]*La/data[-1][1] if a.reaction == "b" else 0.
379+
decay_estimate = -log(target/(intensity + I_buildup))/La if intensity > 0. else 0.
380+
t_guess = max(t_guess, decay_estimate)
381+
#print("corrected at t=0", [Ia for Ia, La, reaction in data])
382+
363383
# Build f(t) = total activity at time T minus target activity and its
364-
# derivative df/dt. f(t) will be zero when activity is at target
365-
f = lambda t: sum(Ia*exp(-La*t) for Ia, La in adj) - target
366-
df = lambda t: sum(-La*Ia*exp(-La*t) for Ia, La in adj)
367-
#print("data", data, [])
368-
t, ft = find_root(0, f, df, tol=tol)
384+
# derivative df/dt. f(t) will be zero when activity is at target.
385+
# 2026-05-27 PAK: include post exposure granddaughter buildup
386+
def f(t):
387+
total = 0
388+
for k, (Ia, La, reaction) in enumerate(data):
389+
total += Ia*exp(-La*t)
390+
if reaction == "b":
391+
# For delayed β- decay use Bateman equation with parent activity
392+
# and half-life from the previous row in the table.
393+
Ip, Lp, _ = data[k-1]
394+
total += Ip*(exp(-Lp*t) - exp(-La*t))/(1 - Lp/La)
395+
return total - target
396+
def dfdt(t):
397+
total = 0
398+
for k, (Ia, La, reaction) in enumerate(data):
399+
total += -La*Ia*exp(-La*t)
400+
if reaction == "b":
401+
# For delayed β- decay use Bateman equation with parent activity
402+
# and half-life from the previous row in the table.
403+
Ip, Lp, _ = data[k-1]
404+
total += Ip*(La*exp(-La*t) - Lp*exp(-Lp*t))/(1 - Lp/La)
405+
return total
406+
t, ft = find_root(t_guess, f, dfdt, tol=tol)
407+
369408
percent_error = 100*abs(ft)/target
370409
if percent_error > 0.1:
371410
#return 1e100*365*24 # Return 1e100 rather than raising an error
372411
msg = (
373412
"Failed to compute decay time correctly (%.1g error). Please"
374413
" report material, mass, flux and exposure.") % percent_error
375414
raise RuntimeError(msg)
376-
# Return time at least zero hours after removal from the beam. Correct
377-
# for time adjustment we used to stablize the fit.
378-
return max(t+guess, 0.0)
415+
416+
# Return time at least zero hours after removal from the beam.
417+
return max(t, 0.0)
379418

380419
def _accumulate(self, activity: dict["ActivationResult", list[float]]):
381420
for el, activity_el in activity.items():
@@ -451,7 +490,7 @@ def find_root(
451490
x: float,
452491
f: Callable[[float], float],
453492
df: Callable[[float], float],
454-
max: int=20,
493+
maxiter: int=20,
455494
tol: float=1e-10,
456495
):
457496
r"""
@@ -463,8 +502,8 @@ def find_root(
463502
Returns x, f(x).
464503
"""
465504
fx = f(x)
466-
for _ in range(max):
467-
#print(f"step {_}: {x=} {fx=} df/dx={df(x)} dx={fx/df(x)}")
505+
for _ in range(maxiter):
506+
# print(f"step {_}: {x=} {fx=} df/dx={df(x)} step={-fx/df(x)}")
468507
if abs(fx) < tol:
469508
break
470509
x -= fx / df(x)
@@ -660,6 +699,9 @@ def activity(
660699
if not hasattr(isotope, 'neutron_activation'):
661700
return result
662701

702+
# Hack to support b-mode 2-stage decay chain
703+
# Relies on b line following directly after activation line
704+
last_activity = 0.
663705
for ai in isotope.neutron_activation:
664706
# Ignore fast neutron interactions if not using fast ratio
665707
if ai.fast and env.fast_ratio == 0:
@@ -707,7 +749,7 @@ def activity(
707749
if ai.reaction == 'b':
708750
# Column N: 0.69/t1/2 [1/h] lambda of parent nuclide
709751
parent_lam = LN2 / ai.Thalf_parent
710-
# Column O: Activation if "b" mode production
752+
# Column O: Activation if "b" mode production (i.e., delayed beta)
711753
# 2022-05-18 PAK: addressed the following
712754
# Note: problems resulting from precision limitation not addressed
713755
# in "b" mode production
@@ -723,6 +765,7 @@ def activity(
723765
# = root * (lam*expm1(x2) - parent_lam*expm1(x1)) / (parent_lam - lam)
724766
# Checked for each b-mode production that small halflife results are
725767
# unchanged to four digits and Eu[151] => Gd[152] no longer fails.
768+
# TODO: 150Nd => 151Nd => 151Pm => 151Sm => 151Eu
726769
activity = root/(parent_lam - lam) * (
727770
lam*expm1(-parent_lam*exposure) - parent_lam*expm1(-lam*exposure))
728771
#print("N", parent_lam, "O", activity)
@@ -785,8 +828,26 @@ def activity(
785828
#data = env.fluence, initialXS, flux, root, U, V, W, precision_correction
786829
#print(" ".join("%.5e"%v for v in data))
787830

788-
# TODO: chained activity (e.g., )
789-
result[ai] = [activity*exp(-lam*Ti) for Ti in rest_times]
831+
# 2026-05-27 PAK: multistage decay such as 209Bi -> 210Bi -> 210Po -> 206Pb
832+
# TODO: 151Eu -> 152m2Eu -> 152Eu is missing b-mode 152Gd
833+
# TODO: 150Nd -> 151Nd -> 151Pm -> 151Sm -> 151Eu is treated as a two stage decay
834+
# It is present for 151Eu -> 152m1Eu isomer and 151Eu -> 152Eu ground state.
835+
if ai.reaction == 'b':
836+
# Accumulate build up following the Bateman equation:
837+
# A_d(t) = λ_d/(λ_d - λ_p) A_p(0) (exp(-λ_p t) - exp(-λ_d t))
838+
# Add this to decay of the granddaughter at the end of the exposure:
839+
# + A_d(0) exp(-λ_d t)
840+
result[ai] = [
841+
activity*exp(-lam*Ti) + last_activity * (exp(-parent_lam*Ti) - exp(-lam*Ti)) / (1 - parent_lam/lam)
842+
for Ti in rest_times
843+
]
844+
# print(f"{ai.daughter} {activity=} {last_activity=}")
845+
else:
846+
result[ai] = [activity*exp(-lam*Ti) for Ti in rest_times]
847+
# 2025-05-17 PAK: Hack to use 151Nd activation intensity rather than
848+
# the 151Pm intensity when computing the buildup of the 151Sm granddaugter.
849+
if ai.daughter != "Pm-151":
850+
last_activity = activity
790851
#print([(Ti, Ai) for Ti, Ai in zip(rest_times, result[ai])])
791852

792853
return result

test/test_activation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ def _get_Au_activity(fluence=1e5):
8989
# that is radioactive for a very long time.
9090
sample = Sample('Te', mass=1e13)
9191
env = ActivationEnvironment(fluence=1e8)
92-
sample.calculate_activation(env, rest_times=[1,10,100])
92+
sample.calculate_activation(env, rest_times=[1, 10, 100])
9393
#sample.show_table(cutoff=0)
9494
target = 1e-5
9595
t_decay = sample.decay_time(target)
@@ -101,7 +101,7 @@ def _get_Au_activity(fluence=1e5):
101101
# Al and Si daughters have short half-lives
102102
sample = Sample('AlSi', mass=1e3)
103103
env = ActivationEnvironment(fluence=1e8)
104-
sample.calculate_activation(env, rest_times=[100,200])
104+
sample.calculate_activation(env, rest_times=[100, 200])
105105
#sample.show_table(cutoff=0)
106106
target = 1e-5
107107
t_decay = sample.decay_time(target)

0 commit comments

Comments
 (0)