This repository has all the code and data in support of our paper.
Read the paper»
Table of Contents
Patrick J. Park - pjp2136@columbia.edu (permanent) Project Link: https://github.com/patrickpark910/b8heisenberg
This is an example of how you may give instructions on setting up your project locally. To get a local copy up and running follow these simple example steps.
- Get a free API Key at https://example.com
- Clone the repo
git clone https://github.com/github_username/repo_name.git
- Install NPM packages
npm install
- Enter your API in
config.js
const API_KEY = 'ENTER YOUR API';
In this section, I'll describe what my (often Python-scripted) MCNP inputs do and how I processed the MCNP outputs to obtain the data I got. I'll go folder-by-folder.
b8_caseR_rings5_d2o96-8_RaBe.inp
The following SDEF
card defines a Radon-Beryllium source. It is modeled as a point source, located at (0, 0, 62)
of the MCNP model (center of the B8). It has a distribution of emitted neutron energies d1
defined by the energy bins in MeV in si1
and the probability of each bin in sp1
, e.g., no neutrons < 0.316 MeV, 0.347% between 0.316 MeV to 0.398 MeV, etc. This data is from the IAEA Compendium of Neutron Spectra (1990), p.79.
sdef pos= 0 0 62 erg=d1 par=n
si1 0.316 0.398 0.501 0.631 0.794 1.00 1.26
1.58 2.00 2.51 3.16 3.98 5.01 6.31
7.94 10.0 12.6
sp1 0 3.47e-3 2.66e-3 1.65e-3 2.03e-3 1.06e-2 2.75e-2
5.19e-2 6.55e-2 7.31e-2 9.95e-2 1.64e-1 1.78e-1 1.21e-1
1.41e-1 5.71e-2 1.39e-3
Having defined our RaBe source, this F4
tally card meaures the volume flux per source neutron in the heavy water (material 1100
) + all the fuel cubes (mats 2001
-6024
).
fc14 n flux in hwtr + fuel
f14:n (1100 2001 2002 2003 2004
...
6023 6024)
This NPS
card states we will run the calculation with 1e6
source neutrons emitted from our RaBe source. The F4
tally normalizes results to be per 1 source neutron anyways, so bigger the NPS
the better fidelity. Then, you can manually scale your flux to the real number of neutrons being emitted by the RaBe source per second, if you know its activity (which we do not for the B8).
nps 1e6
This KCODE
card calculates KSRC
), 115 active cycles, and 15 discarded cycles (giving time for the neutron population to converge). I commented it out (c
) because I had calculated
c kcode 200000 1 15 115
o_b8_caseR_rings5_d2o96-8_RaBe.o
The F4
tally results can be found by searching for "1tally 14
":
1tally 14 nps = 1000000
+ n flux in hwtr + fuel
tally type 4 track length estimate of particle flux. units 1/cm**2
particle(s): neutrons
cell a is (1100 2001 2002 [...] 6024)
volumes
cell: a
1.48300E+06
cell (1100 2001 2002 [...] 6023 6024)
energy
1.0000E-09 7.96402E-07 0.0029
1.0267E-09 4.22215E-08 0.0049
1.0541E-09 4.44174E-08 0.0049
1.0822E-09 4.71006E-08 0.0048
[...]
1.9480E+01 9.35945E-11 0.2124
2.0000E+01 8.17292E-11 0.2549
total 2.26368E-03 0.0027
We have energy bins, the neutron flux tallied within each energy range, and the error. We just copy this into a .csv
and plot it in Excel (or Python matplotlib).
This is where understanding my code gets a little tricky, because I wrote a huge Python wrapper to automate writing + processing MCNP for me. I don't think anyone will really use my code anyways, but I'll write it out just as due diligence.
Here's the folders in this directory:
- ./Figures/ - For data processing. I don't have any scripts that touch this folder.
- ./MCNP/ - Where my Python wrapper writes MCNP inputs and stores MCNP outputs + the
temp
orary directory for when MCNP is actively writing. You should not have to touch anything in here unless MCNP throws an error, in which case you need to go in and manually delete the erroneous input or output (failsafe to prevent any unintentional overwrites). - ./Results/ - Where the CSVs collating results get written.
- ./Source/ - Where my Python wrapper scripts and MCNP templates are kept. If you just want to replicate my data exactly, you shouldn't need to touch this folder. If you want to tweak certain parts of my sensitivity analysis, e.g., test criticality at an even lower moderator purity, then you only need to touch
Parameters.py
I'll now try to walk through executing my code and what happens under the hood.
Ideally, executing this all is relatively simple (duh, that's the point of scripting everything, except for the 7,621 times it breaks!). In your terminal, cd
to this directory and use:
python B8Analysis.py -r <run_type> -t <tasks>
You have three options for <run_type>
or 'cases':
A
: perturb U cube densityB
: perturb D2O mol% purity (remainder as H2O)J
: I have legacy data about case J where I elongate the cubes of chains from (9 + 8) to (10 + 9).R
: reference fiducial cases
(When I was really deep in the trenches, I actually had A
through S
)
The <tasks>
is the number of logical processors you have on your computer. You can leave the -t
argument empty, and B8Analysis.py
will check how many you have for you.
Let's say we want to perturb the U cube density, so we use run_type = A
, and I have 32 logical processors :
python B8Analysis.py -r A -t 32
Step 1
Now that we've executed run type A
, from B8Analysis.py
, we then read the FUEL_DENSITIES
we want to test from Parameters.py
.
elif run_type == 'A':
for fuel_density in FUEL_DENSITIES:
current_run = Sensitivity(run_type,
tasks,
print_input=check_mcnp,
fuel_density=fuel_density)
This then populates the Sensitivity
class in MCNP_Output.py
:
class Sensitivity(MCNP_File):
which has a MCNP_File
class defined in MCNP_Input.py
:
class MCNP_File:
def __init__(self,run_type,tasks,
print_input=True,
delete_extensions=['.s'], # default: '.s'
r_tank=TANK_RADIUS, # 61.7 cm
h_tank=TANK_HEIGHT, # 124.0 cm
n_rings=5,
[...] )
Step 2
MCNP_Input.py
is where the bulk of the scripting occurs. In the MCNP_File
class, we first define, calculate, etc. all of the necessary physical parameters. To do so, I also call several helper functions from Utilities.py
. For example, we calculate the D2O density to program into the MCNP input, using the helper d2o_temp_K_to_mass_density(d2o_temp_K)
:
self.d2o_density = (1-0.01*self.d2o_void_percent)*d2o_temp_K_to_mass_density(d2o_temp_K)
where we set d2o_temp_K
to the B8's ambient 11 C (AMBIENT_TEMP_K = 284
in Parameters.py
).
Step 3
Then, we use the physical constants to write code chunks in MCNP syntax. For instance, we need to call the necessary cross section libraries for particle transport in the moderator. Like described in the paper, we want to perform temperature interpolation. So in MCNP_Input.py
we run the function find_xs_libs()
:
def find_xs_libs(self):
self.d2o_mats_interpolated_str = wtr_interpolate_mat(self.d2o_temp_K, d2o_atom_percent=self.d2o_purity)
which calls wtr_interpolate_mat()
from Utilities.py
:
def wtr_interpolate_mat(temp_K, d2o_atom_percent=0):
[...]
return wtr_mats_interpolated
For a given temperature in Kelvin temp_K
, this function performs pseudomaterial interpolation for a water mixture of D2O and H2O. Mathematically, this interpolation is represented as the following. For desired temperature
and the fraction of the higher-temperature library is
Let's run wtr_interpolate_mat()
by itself (just execute Utilities.py
itself) to see how
>>> wtr_interpolate_mat(294, d2o_atom_percent=100)
"1002.00c 2.00 8016.00c 1.00"
>>> wtr_interpolate_mat(294, d2o_atom_percent=50)
"1001.00c 1.00 1002.00c 1.00 8016.00c 1.00"
>>> wtr_interpolate_mat(284, d2o_atom_percent=96.8)
"1001.06c 0.0141 1001.00c 0.0499 1002.06c 0.426529 1002.00c 1.509471 8016.00c 1.00"
In the first case of 100% D2O at 294 K (21 C), we can see that isotopes 1002
(D) and 8016
(O) have xs library .00c
exactly at 294 K. So we get 2 deuterons at 294 K (1002.00c 2.00
) and 1 oxygen atom at 294 K (8016.00c 1.00
), i.e., D2O.
In the second case of 50% D2O (and the other 50% H2O), we have 1/2 H2O + 1/2 D2O = 1 H (1001
) + 1 D (1002
) + 1 O (8016
).
The third case of 96.8% D2O + 3.2% H2O at 284 K (11 C) is the card we need for the fiducial B8. Each isotope is split proportionally between the two closest xs libraries (.00c
at 294 K and .06c
at 250 K).
Step 4
We also create the names of our MCNP input and outputs so that its parameters are self-evident from the file name. To avoid any processing issues, we replace any decimals with an underscore _
.
self.base_filename = f"{self.template_filepath.split('/')[-1].split('.')[0]}-case{self.run_type}"
if self.run_type in ['A','B']:
self.input_filename = f"{self.base_filename}-d2o{'{:.1f}'.format(self.d2o_purity).replace('.','_')}-Urho{'{:.2f}'.format(self.fuel_density).replace('.','_')}.inp"
self.output_filename = f"o_{self.input_filename.split('.')[0]}.out"
Some examples of self.input_filename
and self.output_filename
are:
b8-caseA-d2o96_8-Urho15_00.inp
b8-caseA-d2o96_8-Urho19_25.inp
b8-caseB-d2o85_0-Urho18_53.inp
b8-caseB-d2o100_0-Urho18_53.inp
o_b8-caseA-d2o96_8-Urho15_00.out
o_b8-caseA-d2o96_8-Urho19_25.out
o_b8-caseB-d2o85_0-Urho18_53.out
o_b8-caseB-d2o100_0-Urho18_53.out
We can read that case A
keeps the moderator purity constant at the reference 96.8 mol% D2O while changing the fuel density, and vice versa for case B
, just as God (yours truly) intended.
Step 5
Once we do that, we load everything into a dictionary self.parameters
:
self.parameters = {'datetime': self.datetime,
'run_type': self.run_type,
'r_tank' : self.r_tank,
'h_tank' : self.h_tank,
'n_rings': self.n_rings,
[...]
'h_h2o_sab_lib': self.h_h2o_sab_lib,
'd_d2o_sab_lib': self.d_d2o_sab_lib,}
it into the b8.template
MCNP input template using the Jinja2
package:
if self.print_input:
with open(self.template_filepath, 'r') as template_file:
template_str = template_file.read()
template = Template(template_str)
template.stream(**self.parameters).dump(self.input_filepath)
Step 6
Now that the MCNP input file is written, B8Analysis.py
then checks if you have mcnp (check_mcnp
boolean) and if so, calls the function to run MCNP:
elif run_type == 'A': # Uranium Density
for fuel_density in FUEL_DENSITIES:
current_run = Sensitivity(run_type, tasks,
print_input=check_mcnp,
fuel_density=fuel_density)
if check_mcnp:
current_run.run_mcnp()
where run_mcnp()
is in MCNP_Input.py
:
def run_mcnp(self):
if self.output_filename not in os.listdir(self.outputs_folder):
cmd = f"""mcnp6 i="{self.input_filepath}" n="{self.user_temp_folder}/{self.output_filename.split('.')[0]}." tasks {self.tasks}"""
os.system(cmd)
self.mcnp_skipped = False
else:
self.mcnp_skipped = True
Notice that MCNP is writing outputs to a temporary ./MCNP/<run_type>/temp/
folder. After MCNP finishes, we then move the outputs to ./MCNP/<run_type>/outputs/
folder with move_mcnp_files()
in MCNP_Input.py
.
Step 7
Once the MCNP run is completed, B8Analysis.py
then moves the MCNP output file from the /temp/
folder to the /outputs/
folder. (This /temp/
folder gets deleted and created again by MCNP_Input.py
before the next MCNP input is run, so we just move the desired .o
file out and the rest of the outputs we don't need gets trashed.)
current_run.move_mcnp_files(output_types_to_move=['.o'])
current_run.process_keff()
Step 8
In process_keff()
, located MCNP_Output.py
, we then process the MCNP output file. It first sets up a CSV to collate all our results. It then calls extract_keff()
, also in MCNP_Output.py
, to read the output file to look for the calculated
def process_keff(self): # paraphrased for README.md
self.keff_filename = f'{self.base_filename}-keff.csv'
self.keff_filepath = f"{self.results_folder}/{self.keff_filename}"
if self.run_type in ['A']:
self.index_var, self.index_header, self.index_data = self.fuel_density, 'fuel density (g/cc)', FUEL_DENSITIES
elif self.run_type in ['B']:
self.index_var, self.index_header, self.index_data = self.d2o_purity, 'modr purity (mol%)', MODR_PURITIES
print(f"\n __MCNP_Output.py")
self.extract_keff()
df_keff.loc[self.index_var, "keff"] = self.keff
df_keff.loc[self.index_var, "keff unc"] = self.keff_unc
df_keff.loc[self.index_var, "therm n %"] = self.therm_n_frac
df_keff.to_csv(self.keff_filepath, encoding='utf8')
Rinse and repeat Steps 1-8 until you ran and processed all the MCNP runs you want!
This folder has the same structure as ./B8 Fiducial - k_eff - Sensitivity Analyses/. I separated the two because writing the MCNP input for the HCP B8 required different template and calculations in MCNP_Input.py than the fiducial B8. All code in this folder is fully standalone from ./B8 Fiducial - k_eff - Sensitivity Analyses/ and vice versa.
You execute this code identically as B8Analysis.py
, except now it's called B8Optimize.py
just so you can differentiate it. In your terminal, cd
to this directory and use:
python B8Optimize.py -r <run_type> -t <tasks>
You have one option for <run_type>
(I am a generous god):
SC
: perturb moderator volume and number of cubes (set in Parameters.py)
(SC
is just a holdover designation from before I cleaned up all the other different cases I decided not to publish.)
The <tasks>
is the number of logical processors you have on your computer. You can leave the -t
argument empty, and B8Optimize.py
will check how many you have for you. I paid for 32 logical processors so I'm gonna use 32 logical processors:
python B8Optimize.py -r SC -t 32
Ride out the MCNP runs and collect the CSV of all the results in ./Result/SC/.
To make my contourf
plot, you don't need to touch anything, just execute B8ContourPlot.py:
python B8ContourPlot.py
I drew the Unit Cell in my paper as a cylinder, but in the MCNP code it's actually a hexagonal prism (of equivalent Voronoi cross sectional area + volume). I just couldn't be bothered to draw a hexagon in SVG-Edit, which is the software I used to draw figures of the different B8 models. Same difference, though (honestly).
See the open issues for a full list of proposed features (and known issues).
lmao what license