Skip to content

Fixing double delta_impurity scaling - #56

Open
digiacomo4 wants to merge 3 commits into
mainfrom
delta_impurity_bug
Open

Fixing double delta_impurity scaling#56
digiacomo4 wants to merge 3 commits into
mainfrom
delta_impurity_bug

Conversation

@digiacomo4

@digiacomo4 digiacomo4 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor
  • Added comprehensive logging in main.py for reaction rates, sigma_eff, and flux
  • Enhanced decay_chain_calc.py with irradiation scenario and activity logging
  • Created test_cobalt_activity.py for Y1 scenario Co delta impurity testing
  • Updated examples.rst documentation
  • Modified dose.py to log and save in json dose rate variations at specific distances,
  • Typo in parsing.py,
  • BUG fixed in reaction_rate.py

Summary by CodeRabbit

  • New Features

    • Added guidance for generating MCNP source definition files with --write_sdef, including source position, photon spectrum, emission rates, mass defaults, and FM scaling.
    • Added near-field dose estimates at standard distances and saved results for easier review.
  • Bug Fixes

    • Corrected reaction-rate calculations to avoid unintended impurity scaling.
    • Updated irradiation scenario schedules and improved command-line help text.
  • Tests

    • Added regression coverage for cobalt-60 activity and dose calculations.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Updates irradiation scenarios, reaction-rate and activity calculations, near-field dose JSON output, diagnostic logging, regression tests, and documentation for the --write_sdef option.

Changes

Calculation pipeline

Layer / File(s) Summary
Irradiation and activity calculations
src/f4epurity/decay_chain_calc.py, src/f4epurity/reaction_rate.py, src/f4epurity/main.py, tests/test_decay_chain_calc.py, tests/test_cobalt_activity.py
Irradiation schedules, reaction-rate scaling, activity logging, and cobalt activity regression coverage are updated.
Near-field dose output
src/f4epurity/dose.py, tests/test_cobalt_activity.py
Dose estimates at fixed distances are computed for point and line sources, persisted as JSON, and validated for Co-60.
SDEF option documentation
docs/source/examples.rst, src/f4epurity/parsing.py
MCNP source definition output, mass defaults, FM scaling, and CLI help text are documented and corrected.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: alexvalentine94

Poem

A rabbit checks the flux by moonlight,
Counts cobalt photons shining bright.
Near-field doses hop to JSON,
While schedules bloom in each equation.
“SDEF,” I whisper, “looks just right!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main bug fix in reaction_rate.py: removing the double delta_impurity scaling.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch delta_impurity_bug

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@digiacomo4
digiacomo4 marked this pull request as ready for review July 22, 2026 13:45

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/source/examples.rst`:
- Line 93: Revise the documentation statements around the “All results” and FM
descriptions to distinguish the default 1 g, per-gram output from values scaled
to the mass supplied with --m. State that mass-scaled FM represents total
photons per second for the requested component mass, and update the
corresponding occurrences near the other referenced examples consistently.

In `@src/f4epurity/dose.py`:
- Around line 263-299: Update the line-source detection and perpendicular
sampling in the dose calculation block: determine whether the source is a line
from the endpoint coordinates (x1, y1, z1) and (x2, y2, z2), not from len(dose),
and construct each test point by applying a normalized offset perpendicular to
the line direction. Preserve the existing point-source formula only when no
valid distinct endpoint pair is provided, and continue storing line results with
the *_cm_perpendicular keys.

In `@tests/test_cobalt_activity.py`:
- Line 95: Remove the redundant f-string prefixes from the three static print
messages in the affected test code, including the message beginning “✓ F4Epurity
calculation complete:” and the corresponding strings at the other referenced
locations, while preserving their output unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5b206501-2182-44ed-ba58-78eb5ba59484

📥 Commits

Reviewing files that changed from the base of the PR and between bd09d39 and db1516d.

📒 Files selected for processing (8)
  • docs/source/examples.rst
  • src/f4epurity/decay_chain_calc.py
  • src/f4epurity/dose.py
  • src/f4epurity/main.py
  • src/f4epurity/parsing.py
  • src/f4epurity/reaction_rate.py
  • tests/test_cobalt_activity.py
  • tests/test_decay_chain_calc.py

Comment thread docs/source/examples.rst
If multiple sources are provided, by default, it is assumed that all the components where the impurities are located have the same mass. In this way, the dose deviation maps can be easily summed together, and the results are provided as mSv/h/g of the component. If this assumption is not valid, masses (in g) for each component need to be provided through the ``--m`` option. This data can also be provided through the ``--sources_csv`` option by adding a column ``m`` to the `.csv` file.

**All results are output per gram of material.** No newline at end of file
**All results are output per gram of material.**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Clarify FM units when --m is supplied.

These statements imply that the generated FM value is always per gram, but main.py passes the requested mass to PointSource, and PointSource._compute_inventory() scales activities before GlobalPointSource.to_sdef() sums them. With --m, FM is therefore the total photons/second for that component mass; only the default 1 g case is numerically per gram.

Please revise the “all results” and FM descriptions to distinguish the default 1 g output from mass-scaled output.

Also applies to: 104-112, 140-140

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/source/examples.rst` at line 93, Revise the documentation statements
around the “All results” and FM descriptions to distinguish the default 1 g,
per-gram output from values scaled to the mass supplied with --m. State that
mass-scaled FM represents total photons per second for the requested component
mass, and update the corresponding occurrences near the other referenced
examples consistently.

Comment thread src/f4epurity/dose.py
Comment on lines +263 to +299
if hasattr(dose, "__iter__") and len(dose) > 1:
# Line source case - calculate at perpendicular distances
logging.info("Line Source Mode:")
line_center_x = (x1 + x2) / 2
line_center_y = (y1 + y2) / 2
line_center_z = (z1 + z2) / 2

for dist in specific_distances:
# Calculate dose at perpendicular distance from line center
# Move in x-direction for simplicity (could be made more general)
test_point_x = line_center_x + dist
dose_at_distance = dose_from_line_source(
dose, x1, y1, z1, x2, y2, z2,
test_point_x, line_center_y, line_center_z
)
dose_at_distances[f"{dist}_cm_perpendicular"] = float(dose_at_distance)
logging.info(f"Perpendicular distance: {dist:6.1f} cm -> Dose: {dose_at_distance:.3e} μSv/h/g")
else:
# Point source case
logging.info("Point Source Mode:")
for dist in specific_distances:
# Point source: use 1/r^2 formula
dose_at_distance = dose[0] / (4 * pi * dist**2)
dose_at_distances[f"{dist}_cm"] = float(dose_at_distance)
logging.info(f"Distance: {dist:6.1f} cm -> Dose: {dose_at_distance:.3e} μSv/h/g")



# Save dose at specific distances to a JSON file
if x2 is not None and y2 is not None and z2 is not None:
dose_distances_file = f"{run_dir}/dose_at_distances_{x1}_{y1}_{z1}_to_{x2}_{y2}_{z2}.json"
else:
dose_distances_file = f"{run_dir}/dose_at_distances_{x1}_{y1}_{z1}.json"
with open(dose_distances_file, "w") as f:
json.dump(dose_at_distances, f, indent=4)
logging.info(f"Saved dose at specific distances to: {dose_distances_file}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Derive a true perpendicular offset and identify line sources from their endpoints.

line_center_x + dist is only perpendicular for a line with no x component. For an x-aligned line it remains on the source, so the reported *_cm_perpendicular dose is wrong. Also, a one-cell line source enters the point-source branch because detection depends on len(dose).

Proposed fix
-    if hasattr(dose, "__iter__") and len(dose) > 1:
+    is_line_source = all(value is not None for value in (x2, y2, z2))
+    if is_line_source:
         logging.info("Line Source Mode:")
         line_center_x = (x1 + x2) / 2
         line_center_y = (y1 + y2) / 2
         line_center_z = (z1 + z2) / 2
+        direction = np.array([x2 - x1, y2 - y1, z2 - z1], dtype=float)
+        reference_axis = np.array([1.0, 0.0, 0.0])
+        if np.allclose(np.cross(direction, reference_axis), 0):
+            reference_axis = np.array([0.0, 1.0, 0.0])
+        perpendicular = np.cross(direction, reference_axis)
+        perpendicular /= np.linalg.norm(perpendicular)

         for dist in specific_distances:
-            test_point_x = line_center_x + dist
+            test_point_x = line_center_x + dist * perpendicular[0]
+            test_point_y = line_center_y + dist * perpendicular[1]
+            test_point_z = line_center_z + dist * perpendicular[2]
             dose_at_distance = dose_from_line_source(
                 dose, x1, y1, z1, x2, y2, z2,
-                test_point_x, line_center_y, line_center_z
+                test_point_x, test_point_y, test_point_z
             )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if hasattr(dose, "__iter__") and len(dose) > 1:
# Line source case - calculate at perpendicular distances
logging.info("Line Source Mode:")
line_center_x = (x1 + x2) / 2
line_center_y = (y1 + y2) / 2
line_center_z = (z1 + z2) / 2
for dist in specific_distances:
# Calculate dose at perpendicular distance from line center
# Move in x-direction for simplicity (could be made more general)
test_point_x = line_center_x + dist
dose_at_distance = dose_from_line_source(
dose, x1, y1, z1, x2, y2, z2,
test_point_x, line_center_y, line_center_z
)
dose_at_distances[f"{dist}_cm_perpendicular"] = float(dose_at_distance)
logging.info(f"Perpendicular distance: {dist:6.1f} cm -> Dose: {dose_at_distance:.3e} μSv/h/g")
else:
# Point source case
logging.info("Point Source Mode:")
for dist in specific_distances:
# Point source: use 1/r^2 formula
dose_at_distance = dose[0] / (4 * pi * dist**2)
dose_at_distances[f"{dist}_cm"] = float(dose_at_distance)
logging.info(f"Distance: {dist:6.1f} cm -> Dose: {dose_at_distance:.3e} μSv/h/g")
# Save dose at specific distances to a JSON file
if x2 is not None and y2 is not None and z2 is not None:
dose_distances_file = f"{run_dir}/dose_at_distances_{x1}_{y1}_{z1}_to_{x2}_{y2}_{z2}.json"
else:
dose_distances_file = f"{run_dir}/dose_at_distances_{x1}_{y1}_{z1}.json"
with open(dose_distances_file, "w") as f:
json.dump(dose_at_distances, f, indent=4)
logging.info(f"Saved dose at specific distances to: {dose_distances_file}")
is_line_source = all(value is not None for value in (x2, y2, z2))
if is_line_source:
logging.info("Line Source Mode:")
line_center_x = (x1 + x2) / 2
line_center_y = (y1 + y2) / 2
line_center_z = (z1 + z2) / 2
direction = np.array([x2 - x1, y2 - y1, z2 - z1], dtype=float)
reference_axis = np.array([1.0, 0.0, 0.0])
if np.allclose(np.cross(direction, reference_axis), 0):
reference_axis = np.array([0.0, 1.0, 0.0])
perpendicular = np.cross(direction, reference_axis)
perpendicular /= np.linalg.norm(perpendicular)
for dist in specific_distances:
# Calculate dose at perpendicular distance from line center
# Move in x-direction for simplicity (could be made more general)
test_point_x = line_center_x + dist * perpendicular[0]
test_point_y = line_center_y + dist * perpendicular[1]
test_point_z = line_center_z + dist * perpendicular[2]
dose_at_distance = dose_from_line_source(
dose, x1, y1, z1, x2, y2, z2,
test_point_x, test_point_y, test_point_z
)
dose_at_distances[f"{dist}_cm_perpendicular"] = float(dose_at_distance)
logging.info(f"Perpendicular distance: {dist:6.1f} cm -> Dose: {dose_at_distance:.3e} μSv/h/g")
else:
# Point source case
logging.info("Point Source Mode:")
for dist in specific_distances:
# Point source: use 1/r^2 formula
dose_at_distance = dose[0] / (4 * pi * dist**2)
dose_at_distances[f"{dist}_cm"] = float(dose_at_distance)
logging.info(f"Distance: {dist:6.1f} cm -> Dose: {dose_at_distance:.3e} μSv/h/g")
# Save dose at specific distances to a JSON file
if x2 is not None and y2 is not None and z2 is not None:
dose_distances_file = f"{run_dir}/dose_at_distances_{x1}_{y1}_{z1}_to_{x2}_{y2}_{z2}.json"
else:
dose_distances_file = f"{run_dir}/dose_at_distances_{x1}_{y1}_{z1}.json"
with open(dose_distances_file, "w") as f:
json.dump(dose_at_distances, f, indent=4)
logging.info(f"Saved dose at specific distances to: {dose_distances_file}")
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 295-295: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(dose_distances_file, "w")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/f4epurity/dose.py` around lines 263 - 299, Update the line-source
detection and perpendicular sampling in the dose calculation block: determine
whether the source is a line from the endpoint coordinates (x1, y1, z1) and (x2,
y2, z2), not from len(dose), and construct each test point by applying a
normalized offset perpendicular to the line direction. Preserve the existing
point-source formula only when no valid distinct endpoint pair is provided, and
continue storing line results with the *_cm_perpendicular keys.

tolerance = 0.01 # 1%

# Print result for user visibility
print(f"\n✓ F4Epurity calculation complete:")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove redundant f-string prefixes.

These strings contain no interpolation; Ruff reports F541, which can fail linting.

Proposed fix
-    print(f"\n✓ F4Epurity calculation complete:")
+    print("\n✓ F4Epurity calculation complete:")
...
-            pytest.fail(f"Could not find dose conversion factor for Co-60 in any naming format")
+            pytest.fail("Could not find dose conversion factor for Co-60 in any naming format")
...
-    print(f"\n✓ Dose calculation results:")
+    print("\n✓ Dose calculation results:")

Also applies to: 123-123, 147-147

🧰 Tools
🪛 Ruff (0.15.21)

[error] 95-95: f-string without any placeholders

Remove extraneous f prefix

(F541)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_cobalt_activity.py` at line 95, Remove the redundant f-string
prefixes from the three static print messages in the affected test code,
including the message beginning “✓ F4Epurity calculation complete:” and the
corresponding strings at the other referenced locations, while preserving their
output unchanged.

Source: Linters/SAST tools

@Radiation-Transport

Copy link
Copy Markdown
Member

This pull request wants to close this issue #55

@alexvalentine94

Copy link
Copy Markdown
Collaborator

Thanks @digiacomo4 & @Radiation-Transport. I will look into this no later than 29.07.

@teade for information.

@alexvalentine94 alexvalentine94 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Matteo for this work. Having looked at this independently I would agree that there was a bug in double counting the delta_impurity. Reviewing the report, I can see that while we did relatively rigorous V&V they were focused on specific aspects of the code rather than complete end to end tests which you have integrated.

@teade also performed an independent check and agrees.

I have left some minor comments for consideration.

Comment thread docs/source/examples.rst

- The source position (x, y, z coordinates)
- The photon energy spectrum from the activated impurities
- The photon emission rate (FM value) expressed in photons per second per gram of material

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Be more explicit here, 'first entry on the FM, tally multiplier card, in MCNP'

# REF : ITER_D_8WK64Y
# old SA2 and generic test and 1 year of irradiation scenarios included for testing purposes.
# REF for DT1 and DT2: CXM7AR v1.3 Safety irradiation scenario
IRRAD_SCENARIOS = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have not checked the DT1 and DT2 scenarios. Will assume correct but please double check as these are hard coded.


# Calculate the reaction rate
reaction_rate = (delta_impurity / 100) * sigma * total_flux * 1e-24
reaction_rate = sigma * total_flux * 1e-24

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree that we were double counting and that this should be removed from here.


# Calculate the reaction rate
reaction_rate = (delta_impurity / 100) * sigma_eff * total_flux * 1e-24
reaction_rate = sigma_eff * total_flux * 1e-24

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above

Comment thread src/f4epurity/main.py
sigma_value = float(sigma_eff.flat[0])
else:
sigma_value = float(sigma_eff)
logging.info(f" Reaction {parent} -> {product}: sigma_eff = {sigma_value:.6e} barns")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the effective cross sections we looked at in the report are 0.1 -> 100. Is the 6 decimal place printing excessive?

Comment thread src/f4epurity/main.py
logging.info(f" Flux spectrum: {flux_spectrum}")

# Calculate the reaction rate based on the flux and effective cross section
reaction_rate = calculate_reaction_rate(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can remove delta_impurity from the function.

activity = (
final_nuclides[nuclide] * decay_data_dic[nuclide]["lambda"][0]
)
n_atoms = final_nuclides[nuclide]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. Just readability changes and addition of logging.

Comment thread src/f4epurity/dose.py
specific_distances = [1, 10, 50, 100, 200] # distances in cm
dose_at_distances = {}

logging.info("\n--- Dose at Specific Distances ---")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is output by default, do we want to make optional? Happy either way.

Perhaps we should also add a couple of lines in the documentation on this feature and why it has been added.

reaction_rate_co60 = calculate_reaction_rate(delta_impurity, sigma_eff_co60, flux_spectrum)
reaction_rate_co60m = calculate_reaction_rate(delta_impurity, sigma_eff_co60m, flux_spectrum)

# Calculate number of atoms for Co-59

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do this with F4Epurity rather than manually?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this - it is very good to have these integration tests. My only comment is that all of the print statements are unnecessary. With the unit/integration testing we are really only concerned if the test passes/fails. These are just additional noise when you run pytest.

sigma_eff_co60m = 3.873241 # barns

# Calculate reaction rates using F4Epurity function
reaction_rate_co60 = calculate_reaction_rate(delta_impurity, sigma_eff_co60, flux_spectrum)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can remove the delta_impurity variable


# Calculate reaction rates using F4Epurity function
reaction_rate_co60 = calculate_reaction_rate(delta_impurity, sigma_eff_co60, flux_spectrum)
reaction_rate_co60m = calculate_reaction_rate(delta_impurity, sigma_eff_co60m, flux_spectrum)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as above

@alexvalentine94

Copy link
Copy Markdown
Collaborator

Once approved, we can bump to v1.1.0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants