Climate Explorations Supplement: Climate Model Scripts
As promised, I’m going to do a quick overview of the python scripts I use to (for the most part) automate the process of running ExoPlaSim models for these climate explorations. Most of this is just to make it easier to run large batches of models; I don’t imagine that users running a single model will find much use with them, but I figured I’d make them available and explain their function for anyone who does want to do something similar.
Before we start, a couple notes:
- I’m assuming here that you’ve already got ExoPlaSim installed and running and that you understand the basic workings of the model as explained in the tutorial.
- I don’t expect any in-depth coding experience here, but I won’t be reviewing the basic concepts of coding or construction of python scripts here; you can look at the official tutorials if you’re not familiar with them (you probably only need to get through the first 4 or 5 sections to follow what I’m doing here)
- I’ve written all these scripts to function on an OpenSUSE partition, and they involve a fair bit of moving, editing, deleting, or overwriting of files; I know users who have installed OpenSUSE on a virtual machine occasionally encounter permissions issues with those sorts of functions, though without using a VM myself, I can’t be sure exactly what issues might arise or how to correct them.
This process uses a handful of scripts operating together, some of which
are written in a somewhat nonlinear way to account for various special
cases, so I may have to jump around a bit. But to start, here’s a fairly
straightforward script; in this case, the one I use to model earth at
45° obliquity:
We’ll start from the top:
import configparser
import os
These lines import the three packages we’ll be using in this script: exoplasim to run the model, configparser
to read and write small configuration files, and os
to work with file directories. The latter two are part of a default python
install.
prev='earth30ax15ex'
name
will be used for naming the files and folders used in the model; critically,
it should be the same as the name for the script (without the “.py”). prev
is the name for the previous model (in this case, the last step of the
30° obliquity model), the
output of which will be used as the start of this model.
The next block identifies the year the model should start (lastyear) at and the restart file it should use (filename).
lastyear=0
First, it first sets lastyear
to a default of 0, as appropriate for a new model.
if os.path.exists(name+'_crashed'):
Then it checks to see if there is a “crashed” folder associated with this
model’s name, indicating that the model has been run before and crashed. In
that case, it will aim to restart the model at a point 10 years before the
crash (this is similar to the crashtolerant
configuration option ExoPlaSim includes, but because this is performed once
when the script starts, I can tweak parameters between each attempt to run
again and limit the number of attempts to restart to avoid getting trapped
in an infinite loop).
if lastyear < 90:
filename = name+'_crashed/MOST_REST.000' + str(lastyear+10)
elif lastyear < 990:
filename = name+'_crashed/MOST_REST.00' + str(lastyear+10)
else:
filename = name+'_crashed/MOST_REST.0' + str(lastyear+10)
if os.path.exists(filename):
lastyear += 1
else:
break
This loop searches for the appropriate year. Starting with the initial lastyear
of 0, it searches in the crash folder for a restart file 10 years after that
year (requiring slightly different names for different orders of magnitude
because of how the file names are formatted, though it’s assumed here that
you’ll never run a model for more than 10,000 years). If it finds such a
file, it increases lastyear
by 1 and tries again; the first time it fails to find the appropriate
restart file, it breaks the loop, leaving lastyear
at that last value 10 years before the first missing year.
filename = prev+'_out/'+prev+'/MOST_REST.00009'
elif lastyear < 100:
filename = name+'_crashed/MOST_REST.000' + str(lastyear)
elif lastyear < 1000:
filename = name+'_crashed/MOST_REST.00' + str(lastyear)
else:
filename = name+'_crashed/MOST_REST.0' + str(lastyear)
Next, it assigns a filepath to the appropriate restart file to filename
(which was just used for convenience in the last block). If lastyear
is still at 0, indicating that the model ran for less than 10 years before
crashing, it refers to the output of the previous model (here, the last year
of the 10-year run I do at the end of each model, as I’ll explain later).
Otherwise, it refers to the restart file in the crash folder corresponding
to lastyear.
filename = prev+'_out/'+prev+'/MOST_REST.00009'
And if no “crashed” folder exists, meaning the model is running for the
first time, it also refers back to the output of that previous model. If
this model is being run from scratch with no previous model, then I instead
assign some placeholder value like “0” or “none” to filename; if exoplasim is told to use a restart file that it can’t find, it will
just automatically start from scratch without one.
check.read('times.ini')
times = check['Time']
step = times.getint('times')
cfg = configparser.ConfigParser()
cfg.read('state.ini')
rpt = cfg['State']
level = rpt.getfloat('level')
We’ll discuss how configs are written and read later, for now all you need
to know is that the script reads a couple of preexisting config files
(“times.ini” and “state.ini”) to find two variables: step, which will be our model timestep, and level, which will control our CO2 and flux levels. If this model is
not running from the output of a previous model, then instead of reading level
from the config file, you should just set it to some reasonable initial
value, e.g. level = 300e-6
fluxlev = 1367
clev = level
else:
fluxlev = 1357 + (level * 1000000)
clev = 10e-6
level
is then used to determine a CO2 level, clev, and a solar flux level, fluxlev. As I’ve mentioned before, I don’t want to use CO2 values below
10 ppm, so if level
is above 10 ppm, it is used as the CO2 level and the flux is set
to the default of 1367 W/m2, but if level
is below that amount, then CO2 is left at 10 ppm and the flux is
reduced instead; level
essentially transitions from adjusting the CO2 level in units of
bar to adjusting the flux level in units of MW/m2 relative to a
baseline of 1357 W/m2 (10 W/m2 less than the default because the switchover happens at 10 ppm), such that a change of 1 ppm in CO2
before is now treated as a change of 1 W/m2 in flux here (which
probably isn’t the actual equivalency, but should be close enough to allow a
smooth transition).
I’ve also made some variants where CO2 is pinned to some set
value (usually 300 ppm) and level
only controls flux, allowing for it to be either decreased or increased from
the default value as necessary.
model.configure(timestep=step, runsteps=int(360*24*60/step), fixedorbit=True, landmap='earth64_surf_0172.sra', topomap='earth64_surf_0129.sra',
pN2=0.78, pO2=0.21, pAr=0.01, pCO2=clev, ozone=True,
eccentricity=0, year=360, rotationperiod=1, obliquity=45, flux=fluxlev,
physicsfilter='gp|exp|sp', restartfile=filename,
wetsoil=True, glaciers={'toggle': True, 'mindepth': 2.0, 'initialh': -1.0}, vegetation=2, initgrowth=0.5)
Here we create and configure the model itself. This should all be familiar from the ExoPlaSim tutorial, with just a few points worth noting:
- workdir=name+”_run” and modelname=name use the established name of the model for naming folders and files, making other operations like finding restart files in the crash folder easier.
- inityear=lastyear sets the lastyear value established earlier as the first year of this run, so the model will just pick up making new output files at the appropriate year if restarted from a crash.
- timestep=step sets the timestep according to that step value read from the config file, and runsteps=int(360*24*60/step) adjusts the runsteps value to ensure that the model years are always equivalent to 360 days, for any timestep (I've made sure that none of the different timesteps used in this process could create issues with non-integer numbers of timesteps in each day or year).
- pCO2=clev and flux=fluxlev set the CO2 and solar flux levels as appropriate given the earlier calculation from level.
- restartfile=filename sets the earlier determined restart file.
model.runtobalance(baseline=20, maxyears=1000, minyears=20,
crashifbroken=True, clean=True)
The model then straightforwardly runs to balance. Once it completes, it
then writes a report on how the model ran:
report = configparser.ConfigParser()
First it creates a reference to the main ConfigParser
function (I probably don’t need to do this anew each time I write or read a
config, but when I was writing these scripts I figured it was better to keep
these code blocks self-contained to make them easier to move around).
tas = str(model.getbalance(key='tas'))
co2 = str(level)
Then it extracts some information on the model’s current state; the current
year, the global average 2-meter air temperature, and the level
value (which I refer to here as co2, though as discussed it isn’t necessarily the actual CO2
value).
'outcome' : 'Success',
'year' : year,
'tas' : tas,
'co2' : co2
}
This information is formatted as a config file. The ConfigParser
function writes out information as named variables organized into sections;
here there’s just one section, ‘Report’, containing those variables we just found along with the model name and
outcome (presumably successful if it’s run this far without crashing), but
you can create multiple sections in this way all attached to that same report
object, and they’d all be written together to the same file (this is how the
koppenpasta.py script makes config and color list files).
model.finalize(name+'_out', allyears=False, clean=True,
keeprestarts=True)
The model is then finalized and moved to an output folder; because I’m
running a lot of these models, I save hard drive space by only saving the
last year of the model and cleaning out the run folder, though I of course
save the restart file to allow for the next model to be run off this
one.
with open('report.ini', 'w') as r:
report.write(r)
I then switch the working directory back to my “home” folder (which will be
named differently depending on your username) and write the report I
prepared earlier to the text file “report.ini”, which is either created or
overwritten if it already exists. I think the runtobalance
function alters the working directory, hence the need to switch it back
here. The resulting file should look something like so:
name = earth45ax
outcome = Success
year = 70
tas = 294.1784691278832
co2 = 0.0001725
We’ll discuss what that’s used for later.
Now, as I’ve mentioned before, though I initially want to see how the model
balances out without interference, I also want to see how these models turn
out with average temperatures of around 15
°C, for better comparisons
with modern Earth (and in some cases I want to change the temperature to
other values). As such I then take the output of this model run and feed it
into another script with a bit more complex logic to try and achieve that
average temperature:
Much of this is the same, but let’s look at all the additions:
rept=configparser.ConfigParser()
rept['State'] = {'run' : run,
'level' : level,
'adjust' : adjust,
'ts' : ts,
'ts1' : ts1,
'ts2' : ts2,
'tol' : tol
}
with open('state.ini', 'w') as r:
rept.write(r)
This defines a function which I’ll use a few times that, like writing the
report, takes in some variables (which I’ll explain shortly) and writes them
out to a text file, “state.ini” (which, you may recall, is where we
retrieved our level
value from in the last script), that will look something like so:
run = 4
level = 6.9e-05
adjust = 3.45e-05
ts = 291.8591408808789
ts1 = 291.9569565854738
ts2 = 292.51662414563674
tol = 2.0
We’ll come back to that; moving on:
adjust = level/5
run = 0
goal = 288.15
tolerance = 4
finalt = 1
ts = 288.15
ts1 = ts
ts2 = ts
Next it sets default values for a number of variables we’ll be using. Usually most of these are overwritten in the next couple sections, but they do need initial values for new model run for the first time, so I figured I’d just leave them in. Briefly:
- level sets the CO2 or flux levels, as we’ve previously discussed.
- adjust is the rate at which level is adjusted in the iterative temperature-adjusting function, which here is initially set relative to level.
- run is the current number of iterations through that function, which should always start at 0.
- goal is the desired final temperature (288.15 K here, or 15 °C), which will not be overwritten and so should always be set at the start here.
- tolerance is the deviation from that goal that is tolerated by the function, though each time this is achieved it will be amended down until it reaches finalt.
- finalt is the final tolerance required for the script to complete (1 K here), which again will not be overwritten and so should always be set here.
- ts is the current average temperature of the model, which I’ve assumed to be 15 °C to start out.
- ts1 is the temperature in the previous iteration, which I’ve set equal to ts just as a placeholder.
- ts2 is the temperature 2 iterations ago, which again I’ve set to ts as a placeholder.
We then name the relevant models; prev
is the initial run we are working off of, and the current model is named the
same with “15” appended, as that’s how I’ve named all these temperature-adjusting
scripts. But in case the previous model could not run to balance (typically
because it got too hot), I also add a reference to the prior model that it
was starting from, so that this model can run from that instead.
if os.path.exists(name+'_crashed'):
while True:
if lastyear < 90:
filename = name+'_crashed/MOST_REST.000' + str(lastyear+10)
elif lastyear < 990:
filename = name+'_crashed/MOST_REST.00' + str(lastyear+10)
else:
filename = name+'_crashed/MOST_REST.0' + str(lastyear+10)
if os.path.exists(filename):
lastyear += 1
else:
break
if lastyear == 0:
if os.path.exists(prev+'_out'):
filename = prev+'_out/'+prev+'_restart'
else:
filename = prior+'_out/'+prior+'/MOST_REST.00009'
elif lastyear < 100:
filename = name+'_crashed/MOST_REST.000' + str(lastyear)
elif lastyear < 1000:
filename = name+'_crashed/MOST_REST.00' + str(lastyear)
else:
filename = name+'_crashed/MOST_REST.0' + str(lastyear)
This is all much the same as for the first script, save that if the
previous model didn’t complete, the script can start from the prior model
instead. Then, given that the model crashed, it checks the “state.ini” file
to determine the state of the model prior to the crash. Reading a config
file works in a similar process to writing one:
cfg =
configparser.ConfigParser()
First a name is defined to refer to the ConfigParser function
cfg.read('state.ini')
Then that function is used to read the specific config file.
rpt =
cfg['State']
Then a name can be attached to each section of variables in that file (just
the one here).
level = rpt.getfloat('level')
adjust = rpt.getfloat('adjust')
ts = rpt.getfloat('ts')
ts1 = rpt.getfloat('ts1')
ts2 = rpt.getfloat('ts2')
tolerance = rpt.getfloat('tol')
We can then retrieve the individual variables within that section, using
the names assigned to them when the report was written (compare to the statereport
function above), though by default they’ll all be read from the file as
strings, so configparser
includes some functions to specifically read the variables as integers
(appropriate for counting) or floats (appropriate for unit values).
If the model hasn’t crashed and is being run for the first time, then the
approach is slightly different:
Again it finds the appropriate previous or prior model to start from.
cfg.read('state.ini')
rpt = cfg['State']
level = rpt.getfloat('level')
adjust = abs(level)/5
Then it reads the “state.ini” config file, but in this case only takes level from it; all other variables are left at their initial default, and adjust is set relative to level.
Basically, “state.ini” is continuously updated as the script iterates, and
if the model crashes, it takes all the variables from the last iteration and
tries to pick up where it left off. But if the model is just starting, it
just uses appropriate initial values, save for level
which is preserved between models.
statereport('0',str(level),str(adjust),str(ts),str(ts1),str(ts2),str(tolerance))
These initial values are then immediately written to “state.ini”, such that
if the model promptly crashes, it will use them when it starts again.
with open(name+'_report.txt','w') as f:
f.writelines(lines)
f.close()
The script then creates a new lines object, which is just a list of strings—to start out, just “start”
and “\n”, which is not written out and instead forms a new line when written
to file—and then creates a new text file named after the model to hold data
about how the model runs; this is just for our reference and won’t be read
by another script later, so we don’t need to use configparser to format it.
Retrieving the step variable, setting clev and fluxlev
values based on level, and setting up the climate model are all the same.
After that is the iterative temperature-adjusting function:
balance=0
model.run(years=5,crashifbroken=True,clean=True)
To start off, if the model is being restarted from a crash, it sets the balance
value—which is used to track how close the model is to reaching a climate
balance within tolerances—to 0 and initially runs for 5 years.
But if it’s being run for the first time, this little block is included to
save time; it quickly runs the model for 2 years and then checks if the
average temperature is already within the final tolerance (here, within 1
°C of 15
°C), then balance
is set high to essentially tell the script to skip the whole iterative
function and immediately conclude. Otherwise, balance
is set to 0 and the model finished out an initial 5-year run.
Then (if not already at appropriate balance) the main iterative function
starts,
statereport(str(run),str(level),str(adjust),str(ts),str(ts1),str(ts2),str(tolerance))
At the start of each loop, it writes out the state at the start of the
iteration to “state.ini”
ts1 = ts
ts = model.getbalance(key='tas')
run += 1
year = model.currentyear
The variables are then all updated; ts
is set to the current average temperature, ts1 to ts’s former value, etc., run is iterated by 1, and year
is set to the current model year.
lines=(['\n','\nrun ',str(run),'\nyear ',str(year),'\nlevel
',str(level),'\nadjust ',str(adjust),'\nts ',str(ts)])
This is all saved to the lines
object, formatted such that it will appear as a neat block when written out
to the file, like so:
year 5
level 0.0003
adjust 5.9999999999999995e-05
ts 293.02718480896345
run 2
year 10
level 0.00023999999999999998
adjust 5.9999999999999995e-05
ts 292.2692299133993
(The long lines of 9s or 0s are just harmless floating-point errors, don’t
worry about them).
And then written to the report file:
Because we are using append mode here (the ‘a’ in the function, as opposed to ‘w’
usually used), this text is added to the end of the file rather than
overwriting it, so the file ends up as a continuous report of the state of
the model at each iteration.
while
True:
Within the iteration loop, we then start another loop to determine what
adjustments should be made before the next run (while True: is a convenient way in python to make a loop that just runs until
something in the loop tells it to stop). This is a slightly complex decision
tree, so try to follow closely:
balance = 0
First off, we check if the model is colder than tolerated, in which case we
first indicate the model is not balanced.
if ts - ts1 < tolerance/4:
If the model has not substantially warmed in the last run, than we need to
try to raise level to warm it, but first:
adjust /= 2
If the model was previously warmer than our goal but now got substantially
colder, then it seems that we tried cooling the model in the last iteration
but overshot the goal, and so adjust
is halved to allow for finer adjustments.
adjust *= 2
But otherwise if the model has been consistently cooling for the last two
iterations, then it’s likely we’ve been trying to warm the model and
failing, so adjust
is doubled for more aggressive warming.
level += adjust
If neither case is true, adjust
is left as-is, and in any case, level is increased.
adjust /= 2
level -= adjust
On the other hand, if the model is warming very quickly, such that it
appears likely to overshoot the goal in the next iteration, then we’ve
likely been trying to warm the model and overdid it, so adjust
is halved and level is reduced to slow the warming.
break
If the model is warming between these limits, then level is unadjusted on
the assumption that the model is warming towards our goal and just needs to
be given more time. In any case, the loop is broken to proceed to the next
step.
balance = 0
if ts1 - ts < tolerance/4:
if ts1 < goal:
adjust /= 2
elif ts > ts1 and ts1 > ts2:
adjust *= 2
level -= adjust
elif ts1 - ts > ts - goal:
adjust /= 2
level += adjust
break
If the model is colder than tolerated, than the logic is much the same,
just with all the warming/cooling reversed.
if ts > ts1 + tolerance/4:
balance = 0
If the model is within the tolerated temperature range, then it’s still not
considered balanced if the temperature is changing too quickly, as it may
just leave the tolerated temperature range in the near future. In this case,
the model is warming too quickly, and so needs to be cooled.
adjust /= 2
If the model cooled in the last iteration and is now warming too quickly,
then it appears that we’ve been trying to get it to settle at balance and
overcorrected, so adjust is reduced.
break
And regardless, level is reduced.
And if the model is cooling too quickly, much the same deal.
if tolerance > finalt*1.01:
balance = 0
tolerance /= 2
adjust /= 2
If the model is within the tolerated range and not changing too quickly,
then tolerance is reduced (and adjust
with it to allow for finer corrections), and then this whole
loop-within-a-loop repeats to see if the model fits within the new, tighter
tolerances. If it does, this repeats until reaching the final desired
tolerance, finalt
(multiplied by 1.01 here to account for floating-point errors).
balance += 1
break
And at long last, if the model is within that final tolerance, it is regarded as being at balance.
The script then modifies and runs the model based on all these
adjustments:
break
First off, if the model has managed to remain at balance for several
iterations, the function is complete and the loop ends.
model.modify(pCO2=level, flux=1367)
else:
model.modify(pCO2=10e-6, flux=1357 + (level*1000000))
Otherwise, CO2 and flux levels are modified to match the new
adjusted level, following the same logic as when first constructing the model.
And finally, the model itself
runs. If not at balance, it runs for 5 years; if it has just achieved
balance, it runs for 10 years; and if, after that, it has retained that
balance, it properly runs to balance (as defined by ExoPlaSim rather than my
function). All of these runtimes are tweaked in models where the year length
is significantly altered.
This iterative process has generally proved to be fairly reliable in achieving the desired temperature, though it can be a bit slow; even an adjustment by a few degrees can take 100 years of modelling to complete. It can also run into issues when dealing with unstable climates on the cusp of rapid melting or thawing, for example:
- For my exploration of different average temperatures, I attempted to make the model balance at –20 °C, but this was evidently just not possible; below some threshold of CO2, it would freeze over completely and drop to –60 °C, and above the threshold it would warm to –18 °C. The model therefore got trapped endlessly adjusting CO2 up and down close to that threshold in response to rapid temperature shifts which never balanced. Ultimately I just stopped the model, picked a CO2 level just above the threshold, and let it balance there.
- When running the 90° obliquity model, it initially balanced at a low temperature of around –20 °C, with a thick ice belt in the tropics. The model raised CO2 to get it to warm closer to 15 °C, but at around 0 °C the ice belt rapidly melted and average temperatures shot up to over 30 °C, at which point the summer temperatures at the poles were high enough to cause the model to crash before it could bring CO2 levels down again. I ultimately had to write a special function to raise CO2 levels just high enough to melt the ice belt and then immediately drop them to 300 ppm when the model passed 5 °C.
Once that whole process is complete, it then finalizes and writes out both
the final report as with the previous script and the current state of the
model for use with the next model. Note that, because all models use the
same “state.ini” file, if you want one model to inherit the same level
as another, it has to be run directly after it, or you’d have to manually
edit the file or add some logic to record that level elsewhere and refer to it
later.
Once that’s done, a third script runs the model one last time:
This looks much like the first script, but in this case the model runs for
only 10 years and data from all years is retained, allowing them to be
averaged together for producing more reliable climate data. Because it only
runs a short time, there’s also no special case for restarting from a crash;
if it crashes, the model just starts over.
Now that we’ve got these scripts set up, here’s the script I use to
automate the running of
those scripts:
To start:
import sys
import shutil
import configparser
No need for exoplasim here, and I’m also using shutil
to move some files around (also part of the default python install). I honestly don’t remember why I imported sys
in this one, but I don’t want to break anything by removing it and it's a default package.
As is scripting convention, I’ve put my functions at the top of the script
and the main commands at the bottom, so let’s just jump down to the latter
at line 70:
while True:
adjtime(30)
First off I create an empty list, hasrun, which I’ll use in a moment, and then
start a loop; each run of the loop will run one model. The first thing we do
each loop is call the adjtime
function, at line 53:
timer=configparser.ConfigParser()
timer['Time'] = {'times' : str(time)}
with open('times.ini', 'w') as t:
timer.write(t)
This just takes in a given timestep value and then saves it to the
“times.ini” config, which is a pretty straightforward little file:
times = 30
As you may remember, each of the model scripts above reads that config when
it starts and uses it to set its timestep, which allows us to easily adjust
the timestep if the model is having trouble completing.
Back to the main loop:
models = qlist.readlines()
The script opens the text file “queue.txt” and reads out each line.
“queue.txt” is a simple text file I made containing a list of model names in
the order they should be run, like so:
earth30ax15
earth30ax15ex
earth45ax
earth45ax15
earth45ax15ex
earth15ax
earth15ax15
earth15ax15ex
earth5ax
earth5ax15
earth5ax15ex
Having the list of models in an external file and making the script read
that file again each loop means that I can add to or edit the queue of
models to be run while the script is running.
Next:
for m in models:
I’ll explain finished
in a moment; for now, the script looks through that file line by line.
wait = input("WAITING......")
break
If it hits a line that reads as wait, the script will simply wait until told to continue (by pushing “enter”).
This way I can put a pause between models if I want to do something before
the next one in the queue runs. When the script continues, it starts a new
loop and reads “queue.txt” again, so you can change the queue if you need
to; note that you should also remove the wait line in “queue.txt” before continuing the script, or else it will see
that line again and just immediately pause again without moving on in the
queue (I could probably add some more complex logic to work around that, but
it’s not really worth the effort).
finished = False
print("running " + m)
runscr(m.strip())
hasrun.append(m)
break
Otherwise, it checks the model name against the list hasrun, which tracks the models that have already been run this session. If the model hasn’t been run, it changes finished to indicate that the script hasn’t concluded and prints a quick message on screen showing the model it’s running. It calls the runscr function we’ll look at in a moment with the model name (using strip() to remove any extraneous spaces or line breaks) and when that completes, adds the model to the list of models run and stops reading the list, effectively starting a new loop.
Note that this approach means that the script can’t run the same model
multiple times in one session; if you want to try running a model again, you
either have to give it a different name (changing the name of the script and
the name
variable within it as well) or stop this script (simply closing konsole will do it)
and start it again (with “queue.txt” edited to remove any models you don’t
want it to run again, as it won’t remember between sessions).
break
If the script looks through the queue and finds no models it hasn’t already
run, then it’s finished and the script concludes.
Now, the runscr
function, starting at line 6:
attempts=0
runcom = 'python3 ' + model + '.py'
outfold = model + '_out'
while True:
It starts off by setting the number of attempts at running the model to 0,
and then based off the model name, it constructs an appropriate command to
run the model script and works out the name of the output folder produced
when the model successfully completes. Then it starts a loop, with each run
of the loop representing one attempt to run the model to completion.
names = os.listdir(model+'_crashed/')
for name in names:
if os.path.exists(model+'_run/'+name):
pass
elif name.endswith(".nc"):
print('moving '+name)
try:
shutil.copy(model+'_crashed/'+name,model+'_run/')
except:
pass
First, some special logic in case the model has run once and crashed. See,
when the model crashes, it takes everything from the “run” folder and moves
it into the “crashed” folder. The way we’ve written our model scripts, when
they run they’ll pick up 10 years before the crash and put the outputs from
there onwards in the “run” folder. The problem is that the runtobalance
function requires that the .nc data files for every year from 0 onwards be
in the “run” folder to run correctly. As such, the script looks through the
“crashed” directory for every .nc file and, if it doesn’t already exist in
the “run” folder, copies it over. I do this with the nifty try/except
function here, which attempts to copy the file over, and if it encounters an
error, rather than crashing the whole script, it just executes whatever’s
under the except
command (which in this case just tells the script to move on).
try:
shutil.move(model+'_run/state.ini','state.ini')
except:
pass
We also check if the “state.ini” file has been written into the “run” folder rather than “home” for some reason (I think this might happen as a side-effect of the runtobalance function), and if so moves it out to the "home" directory, which is where the model will look for it when it restarts.
os.system(runcom)
The script then checks that the appropriate script exists and, if so, runs
it through a konsole command (this is not usually the recommended approach
to operating functions in one script from another, you should generally
import the script functions as packages, but it serves our purposes here and
allows us to write new scripts and add them to the queue while this process
is running).
print('no script')
failreport(model)
break
If it doesn’t exist, it reports this on konsole, creates a failure report
(which I’ll explain later), and stops the loop to cease attempts to run
it.
os.system('python3 maps.py')
try:
shutil.rmtree(model+'_crashed')
except:
print('no folder')
break
If the model does run, the script then checks if it completed and produced
an output folder; if so, it then runs the external script “maps.py” (which
we’ll look at shortly), attempts to delete the “crashed” folder, if it
exists, to save hard drive space, and breaks the loop.
failreport(model)
break
elif attempts > 15:
adjtime(10)
elif attempts > 10:
adjtime(15)
elif attempts > 5:
adjtime(20)
attempts += 1
If the model crashed, then the loop continues, and every 5 attempts to run
the loop, the timestep is reduced (to 20, 15, and then 10 minutes). If,
after 20 attempts, the model still fails to complete, then the script gives
up and reports a failure.
return
In any case, the script runs the script “report.py” and concludes.
So let’s look at these external functions and scripts, starting with the failreport
function in this script, at line 59:
report=configparser.ConfigParser()
report['Report'] = {'name' : model,
'outcome' : 'Fail',
'year' : '0',
'tas' : '0',
'co2' : '0'
}
with open('report.ini', 'w') as r:
report.write(r)
This is just like the report-writing functions in our model scripts, but
here it reports a failure and fills the variables with placeholders.
Next, the “maps.py” script that runs if the model successfully completes,
which takes the final output of each model and creates some convenient
averaged data files and climate maps from them:
From the start:
import eps_avg as ea
from PIL import Image
import os
import configparser
It imports the koppenpasta and eps_avg
scripts I’ve made previously (and won’t show here; they’re detailed in my
previous tutorial
and in the
koppenpasta repository), as well as the Image
function from Python Image Library, and of course the os and configparser
libraries as we’ve seen before.
As before, the function (just the one here) is on top and the main commands
below them, at line 30:
if __name__ == "__main__":
This means this only runs if the script is called directly, not if it’s
imported as a library (as I use the mappy
function here in some other scripts as well).
cfg.read('report.ini')
rpt = cfg['Report']
model = rpt.get('name')
The script reads the report from the latest model and finds the model
name.
mappy(model)
And then runs the main function:
if os.path.exists(model+'_out/'+model):
path = model+'_out/'+model
else:
path = model+'_out'
That function first finds the appropriate path to the output files, which
are formatted a bit differently by ExoPlaSim depending if there’s a
single-year output (allyears=False in the finalize
function) or multiple years.
outint = path+'/'+model+'_interp'
kp.MakeMap(path, outnorm)
kp.MakeMap(path, outint, interp=8, dum_ice=1, use_topo=1, topo_path='earthscale.png', maxel=6300, minel=0, gravity=9.81, blend_topo=1, sealev=0)
It then creates two climate maps with the koppenpasta script, one using the
default settings and one interpolated to 512x1024 resolution, using an Earth
topography map for temperature adjustment, and then saves both maps to that
model’s output folder with different names. If you wanted to use different
topography maps for different models, there are a couple ways you could
approach that, but I think the simplest would be to give the models using
different topography distinctive names and then add logic here to check the
model name, e.g.:
(and then use those variables in the kp.MakeMap command).
with Image.open(outnorm+'.png') as norm:
im = Image.new("RGB", (1024,740))
im.paste(norm.resize((1024,512), resample=Image.NEAREST))
im.paste(key, (0,512))
im.save(outnorm+'.png')
with Image.open(outint+'.png') as interp:
im = Image.new("RGB", (1024,740))
im.paste(interp)
im.paste(key, (0,512))
im.save(outint+'.png')
Next, the script opens an image of a map key I have waiting in my "home" folder:
And then opens each of the climate maps in turn and pastes the map and key
together (resizing the uninterpolated map up to 512x1024 resolution) into a
new image, which it saves over the existing map images. These are the climate maps you see in the other posts in this series.
return
It then uses the eps_avg script to create averaged Netcdf files as well
(including an annual average file, rotating the longitudes for better
viewing with Panoply, and converting the precipitation units to mm/month)
and the script concludes.
Note that this script makes the maps and averaged data files using all the
.nc files in the model outputs. In these cases where I’m either only
preserving the last year of the model or running a short model of a climate
already at balance, that’s fine, but if you preserve all the years of a
model run, the map and climate files produced here will average that all
together and so not represent the final state of the model.
Finally, let’s look at the “report.py” script, which sends an email to me
at the end of every model showing the outcome and even with one of the
climate maps attached:
From the top:
import smtplib, ssl
import os
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import COMMASPACE, formatdate
It imports our usual configparser
and a bunch of specialized functions for handling emails.
cfg.read('report.ini')
rpt = cfg['Report']
name = rpt.get('name')
out = rpt.get('outcome')
year = rpt.getint('year')
tas = rpt.getfloat('tas')
co2 = rpt.getfloat('co2')
It then reads off the report made by the last model.
It then designates a sender and receiver
email. I would suggest making a new email account specifically to send these
emails to avoid any possible security risk to your existing accounts.
Here we make the main body of the report, which is just a generic message
with the report data attached as a formatted list, which will look something like this when emailed:
Hello sir, we have done more calculations for you
Report:
Name
earth45ax
Outcome
Success
Year
70
tas
294.1784691278832
CO2
0.0001725
Moving on:
This constructs an email object with the appropriate information and a
subject, and attaches the message to it.
try:
if os.path.exists(name+'_out/'+name):
path = name+'_out/'+name+'/'+name+'.png'
else:
path = name+'_out/'+name+'.png'
with open(path, "rb") as fil:
part = MIMEApplication(
fil.read(),
Name=name+'.png'
)
part['Content-Disposition'] = 'attachment; filename="%s"' % name+'.png'
msg.attach(part)
except:
pass
If the report indicates the model succeeded, then the script looks for one
of the climate maps we made previously (the uninterpolated one) and attempts
to add it as an attachment to the email.
password = 'password'
context = ssl.create_default_context()
with smtplib.SMTP_SSL("smtp.gmail.com", port, context=context) as server:
server.login(sender,password)
server.sendmail(sender,receiver,msg.as_string())
Here we send the email itself. How you approach this may vary depending on
the email you use. With gmail, you should use the port 465 and then run
through
this process to get an appropriate password; in short, turn on 2-step
verification, log into the gmail account on the device you’re running models
on, and then get a password that will work only for that device. Even then,
leaving the password in code like this is generally
not recommended if there’s a
chance anyone else might access this code or the device, but in my case I’m
the only one who touches the code or this laptop, so I figure it’s fine;
again, use a disposable email for this.
And that about wraps it up. So, to summarize the whole process:
- queue.py reads the list of models from queue.txt and runs them each in turn
- Each model consists of 3 scripts:
- An initial run to balance with the main parameter altered.
- A model with an iterative function to alter CO2 or solar flux levels to achieve a desired average temperature of 15 °C.
- A final, short run of 10 years as a sample for producing the output data and maps.
- As these models run, they read and write some simple text files:
- times.ini, which allows queue.py to determine the timestep of the models.
- state.ini, which tracks the current state of the model between model runs and crashes.
- report.ini, which records the final state of the model when it completes (or records that it failed to complete).
- [model]_report.txt, named for each model, which records the process of the iterative temperature-adjusting function.
- If a model crashes, queue.py moves the .nc files back into the run directory and the model restarts 10 years before the crash.
- If it crashes again, queue.py reduces the timestep every 5 attempts and then gives up and reports a failure after 20 attempts.
- If a model completes successfully, maps.py creates climate maps and averaged data files from the outputs, and attaches keys to the maps.
- At the end, report.py sends me an email with the reported result and, if the model was successful, a climate map as an attachment.
I hope that all helps and isn’t too tediously detailed. As you might have
guess by my choice of example, the climate models for different obliquities
are coming along nicely and should make for an interesting exploration. I’m
also finally making some
progress on a reliable methodology for creating terrain for the next post in
the main series, so, exciting times ahead.
Hello, I've managed to get the first two scripts to run successfully but I'm encountering errors with the third.
ReplyDelete--------------------------------------------------------------------------
[[54604,1],5]: A high-performance Open MPI point-to-point messaging module
was unable to find any relevant network interfaces:
Module: OpenFabrics (openib)
Host: User
Another transport will be used instead, although this may result in
lower performance.
--------------------------------------------------------------------------
At line 150 of file restartmod.f90 (unit = 33, file = 'plasim_restart')
Fortran runtime error: I/O past end of record on unformatted file
Error termination. Backtrace:
#0 0x7ff09b413640 in ???
#1 0x7ff09b414185 in ???
#2 0x7ff09b4148ea in ???
#3 0x7ff09b585d1e in ???
#4 0x7ff09b581a89 in ???
#5 0x4b6673 in ???
#6 0x451c77 in ???
#7 0x45a251 in ???
#8 0x40222e in ???
#9 0x7ff09ac6324c in ???
#10 0x402289 in _start
at ../sysdeps/x86_64/start.S:120
#11 0xffffffffffffffff in ???
-------------------------------------------------------
Primary job terminated normally, but 1 process returned
a non-zero exit code.. Per user-direction, the job has been aborted.
-------------------------------------------------------
--------------------------------------------------------------------------
mpiexec detected that one or more processes exited with non-zero status, thus causing
the job to be terminated. The first process to do so was:
Process name: [[54604,1],0]
Exit code: 2
--------------------------------------------------------------------------
[User.lan:27962] 7 more processes have sent help message help-mpi-btl-base.txt / btl:no-nics
[User.lan:27962] Set MCA parameter "orte_base_help_aggregate" to 0 to see all help / error messages
Command '['mpiexec -np 8 most_plasim_t42_l10_p8.x']' returned non-zero exit status 2.
Traceback (most recent call last):
File "/home/User/.local/lib/python3.6/site-packages/exoplasim/__init__.py", line 803, in _run
subprocess.run([self._exec+self.executable],shell=True,check=True)
File "/usr/lib64/python3.6/subprocess.py", line 438, in run
output=stdout, stderr=stderr)
subprocess.CalledProcessError: Command '['mpiexec -np 8 most_plasim_t42_l10_p8.x']' returned non-zero exit status 2.
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "Script3.py", line 35, in
model.run(years=10,crashifbroken=True,clean=True)
File "/home/User/.local/lib/python3.6/site-packages/exoplasim/__init__.py", line 387, in run
self._run(**kwargs)
File "/home/User/.local/lib/python3.6/site-packages/exoplasim/__init__.py", line 885, in _run
self._crash() #Bring in the cleaners
File "/home/User/.local/lib/python3.6/site-packages/exoplasim/__init__.py", line 1705, in _crash
raise RuntimeError("ExoPlaSim has crashed or begun producing garbage. All working files have been moved to %s_crashed/"%(os.getcwd()+"/"+self.modelname))
RuntimeError: ExoPlaSim has crashed or begun producing garbage. All working files have been moved to /home/User/Pruned21Auto15ex_crashed/
If it matters, I am running these scripts manually and not automatically. Is there something in the error report that you recognize?
Ah, it was a silly mistake, I'm not sure how I didn't catch it earlier. I had a simulation resolution mismatch on the consecutive scripts. Keep up the good work!
Delete