@@ -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
0 commit comments