Work areas and design studies¶
Solver actions such as DynaAnalysis, RadiossAnalysis, and
OpenFOAMAnalysis expect their input files to be present in the
current working directory when they run, and they leave their output
there. Two classes decide where that directory is and what is kept from
a run: a WorkArea evaluates a graph once in one directory; a
SimulationIterator evaluates it for every design point of a study,
each in a numbered job directory of its own, and keeps an index of them.
Both create the directory and copy the required files into it before the
graph runs.
copy_paths — the files and directories to copy in — is always given
to the WorkArea or SimulationIterator, not to the individual
solver actions.
WorkArea: one run in one directory¶
WorkArea evaluates a graph in a dedicated directory.
Each call to solve() cleans the directory and re-copies the required
files, so previous results are overwritten. This is the right choice
for a single run or when you want to inspect intermediate files after
the run.
from kunene.graph_actions import WorkFlow, WorkArea
from kunene.dyna_actions import DynaAnalysis
from kunene.d3plot_actions import d3plot_File
wf = WorkFlow('SpringWorkFlow')
wf.add_action(DynaAnalysis(name='run', input_path='spring.k'))
wf.add_action(d3plot_File(name='field'))
# 'spring.k' is copied from its source location into ./SpringWorkFlow/
# before the graph runs.
wa = WorkArea(wf, copy_paths=['path/to/spring.k'])
results = wa.solve({'K': 200.0})
By default the work directory is ./{graph.name} — created relative to
the current directory at run time (not the directory in which the
WorkArea was constructed). This means a WorkArea can be nested
inside a SimulationIterator: it is created inside the current
job_N directory rather than next to it. A custom path can be supplied
as the second argument:
wa = WorkArea(wf, work_area_path='~/runs/spring', copy_paths=['path/to/spring.k'])
An explicit relative path is likewise resolved against the current
directory at run time, while an absolute path is used as given. The
directory path may contain ~ and environment variables; both are
expanded automatically.
SimulationIterator: a design study¶
SimulationIterator evaluates a graph once per design point, placing
each run in its own numbered subdirectory (job_0, job_1, …).
The required files are copied into each job directory before the graph
runs. Use this when you need to keep the results from every run, for
example during a parameter study or optimisation.
Job directories¶
from kunene.graph_actions import WorkFlow, SimulationIterator
from kunene.dyna_actions import DynaAnalysis
from kunene.d3plot_actions import d3plot_File
wf = WorkFlow('SpringWorkFlow')
wf.add_action(DynaAnalysis(name='run', input_path='spring.k'))
wf.add_action(d3plot_File(name='field'))
itr = SimulationIterator(wf, copy_paths=['path/to/spring.k'])
# Each call to solve() creates a new job_N subdirectory.
itr.solve({'K': 100.0}) # writes to SpringWorkFlow/job_0/
itr.solve({'K': 200.0}) # writes to SpringWorkFlow/job_1/
The resulting directory layout is:
SpringWorkFlow/
├── jobs_index.json
├── status.json # run progress; see kunene.progress
├── variables_discovery/ # copies used to read the deck's variables
├── job_0/
│ ├── iter_variables.json
│ ├── actions_output.pkl
│ ├── status.json # per-action progress for this job
│ └── ... # the deck, the solver's output files
├── job_1/
│ ├── iter_variables.json
│ └── actions_output.pkl
Each job records the variable values it was run with
(iter_variables.json) and what its actions computed
(actions_output.pkl); jobs_index.json at the root lists the jobs
and is what the retrieval methods below use. variables_discovery
appears when the iterator has to discover the variables itself:
parameters() copies copy_paths there and reads the deck. Passing
parameter_list explicitly skips it.
An existing results directory is added to. Job numbers continue after whatever is already there — taken from the index and the directories on disk, not from a counter on the iterator — so a study can be extended in a later session and a finished job is never written over:
itr = SimulationIterator(wf, copy_paths=['path/to/spring.k'])
itr.solve({'K': 300.0}) # a later session: writes job_2, not job_0
Pass clean_start=True to delete the results directory (jobs, index
and all) before starting:
itr = SimulationIterator(wf, copy_paths=['path/to/spring.k'], clean_start=True)
Running a sweep¶
solve evaluates one design point. A study is normally run as a
sweep, which evaluates a whole set of them and returns the results side
by side. collect_for_varrange takes the values to try for each
variable and evaluates every combination:
pars, out = itr.collect_for_varrange({'K': [100., 200., 300.],
'T': [10., 20.]}) # 6 jobs
pars['K'] # array([100., 100., 200., 200., 300., 300.])
pars['T'] # array([ 10., 20., 10., 20., 10., 20.])
out['field'] # [<job_0 result>, <job_1 result>, ...]
Two dictionaries come back: pars maps each variable name to an array
of the values it took, one entry per job, and out maps each action
name to a list of what that action computed, in the same order — the
order the design points were generated in. So pars['K'][i],
pars['T'][i] and out['field'][i] all describe the same job, and
the two can be handed to a plotting routine as they are. out also
carries the variable values themselves (out['K'], as plain lists),
since each job’s output starts from the values it was given. An action
that returns a dictionary (a WorkArea nested in the graph, say) shows
up as a dictionary of lists.
collect_for_expdes runs an experimental design you build yourself —
a list of value tuples and the variable names they belong to — for a
Latin hypercube, a sample from an optimiser, or any set of points that
is not a full grid:
points = [(100., 10.), (150., 25.), (300., 15.)]
pars, out = itr.collect_for_expdes(points, ['K', 'T'])
Both methods take dependent_pars, a dictionary of variables computed
from the swept ones by a Python expression, so a deck variable that must
follow another can be kept in step without being swept itself:
pars, out = itr.collect_for_varrange({'K': [100., 200., 300.]},
dependent_pars={'T': 'K / 10.'})
solve_parallel is the third sweep method; it takes an explicit list of
{variable name: value} dictionaries and returns one result dictionary
per point, and is what the other two use underneath.
Running jobs in parallel¶
The jobs of a sweep run one at a time unless the iterator is given
max_workers, which evaluates that many design points at once, each in
a child process with its own job directory:
itr = SimulationIterator(wf, copy_paths=['path/to/spring.k'], max_workers=4)
pars, out = itr.collect_for_varrange({'K': [100., 200., 300., 400., 500.]})
The results, the job directories and the index are the same as with the
default max_workers=1, and come back in the same order. See
Running in parallel for the progress bars, the failure semantics and what a
child process needs on Windows.
Naming the job directories¶
The job directories are named job_0 … job_N; the prefix comes from
the class attribute SimulationIterator.JNAME and can be changed if
another name suits better — on the instance, or in a subclass when a
whole family of studies uses it:
itr = SimulationIterator(wf, copy_paths=['path/to/spring.k'])
itr.JNAME = 'design_'
itr.solve({'K': 100.0}) # writes to SpringWorkFlow/design_0/
class DesignIterator(SimulationIterator):
JNAME = 'design_'
Change the prefix on an empty results directory. Jobs already written
under the old prefix stay in the index and can still be read back by
variable value, but they no longer take part in the numbering, so the new
prefix starts its own series at 0.
Variable values are not encoded in the directory name — they are
recorded in jobs_index.json, which is what the retrieval methods
below use.
Retrieving past runs¶
jobs_index.json at the results root maps each job directory to the
variable values it was run with, its state, and any group labels:
{ "jobs": [ { "job": "job_0",
"groups": [ "baseline" ],
"variables": { "K": 100.0 },
"state": "done",
"created_at": 1753.0, "updated_at": 1755.0 } ] }
Point a new SimulationIterator at an existing results directory and
ask it for results by variable value. Nothing is executed:
itr = SimulationIterator(wf, work_area_path='SpringWorkFlow')
out = itr.results_for({'K': 100.0}) # the stored actions_output.pkl
path = itr.find_job(where={'K': 100.0}) # SpringWorkFlow/job_0
results_for prefers the job whose variables are exactly the ones
given, and otherwise accepts an unambiguous partial match, so
results_for({'K': 100.0}) works in a study that also varied T
as long as only one job has that K. It raises DataNotFoundError
when no job matches and when several do.
The index is a cache, never the authority. itr.job_index(rebuild=True)
re-derives it by reading the job directories, so result trees created
before the index existed keep working; only the group labels — which
exist nowhere else — are lost if the file is deleted.
Grouping runs¶
A job may carry any number of group labels. Set a default for the iterator, per sweep, or per job — the most specific wins:
itr = SimulationIterator(wf, groups='baseline') # default for all jobs
itr.collect_for_varrange({'K': [100., 200.]}) # -> 'baseline'
itr.collect_for_varrange({'K': [300.]}, groups='stiff')
itr.solve({'K': 400.}, groups=['stiff', 'hand_check'])
itr.groups = 'later_runs' # from here on
Because the labels are metadata rather than directories, runs can be grouped long after they finished, and one job can belong to several groups:
itr.add_groups('converged', where={'K': 100.0})
itr.add_groups('report', jobs=['job_0', 'job_3'])
itr.remove_groups('stiff', jobs=['job_2'])
itr.group_names() # ['baseline', 'converged', ...]
Read a group back in the same form a sweep returns — so grouped runs can be plotted or post-processed like a fresh study:
pars, out = itr.collect(groups='baseline')
# pars: {'K': array([100., 200.])}, out: {'disp': [...], ...}
pars, out = itr.collect(groups=['baseline', 'converged'],
match_all_groups=True) # in both groups
jobs = itr.find_jobs(groups='report', state=None)
Reusing completed runs¶
With reuse_existing=True a design point that already has a completed
job is not run again — its stored outputs are returned and its labels
extended, while new design points run as usual:
itr = SimulationIterator(wf, work_area_path='SpringWorkFlow',
reuse_existing=True, groups='study_2')
pars, out = itr.collect_for_varrange({'K': [100., 500.]})
itr.reused_jobs # ['job_0'] — K=100 came off disk
The flag only decides whether computed design points are skipped; adding to an existing results directory needs no flag. Without it every design point given is run, appended after the jobs already there — so the same values can appear in several jobs, which is what you want when a deck or a solver version changed. Note that reuse matches on the complete set of variable values, so a job that differs in any variable is treated as a different design point.
A sweep that was aborted — by a failing job, or by you — is resumed the
same way: run it again with reuse_existing=True and only the design
points without a completed job are evaluated.
Cleaning up bulk solver output¶
It is easy to fill up a disk by keeping all the solver output in every job directory.
The solver output being the plot databases, the OpenRadioss animation files, the converted VTK, etc.
Pass a Cleanup policy to have them removed once a run has finished:
from kunene import Cleanup
itr = SimulationIterator(wf, copy_paths=['path/to/spring.k'],
cleanup=Cleanup(keep=['d3plot']))
Each job is cleaned as soon as its graph has run to the end — not
before, so a d3plot_File action downstream of the solver has already
read what it needed. A job that failed is not cleaned, leaving you the deck and
solver log to you debug it with. cleanup=True selects the
default policy; the default, None, keeps everything as before.
What may be removed. The actions declare their own bulk output, so a policy normally names no files at all:
Cleanup(remove='bulk')The default. The field output the actions declare as disposable:
d3plot*,d3thdt*,d3dump*,runrsf*,binout*for LS-DYNA; the animation and restart files (plus the d3plot and VTK conversions, when those are enabled) for OpenRadioss;VTK/andprocessor*for OpenFOAM. Input decks, solver logs and small time-history files are never in this set — they are what a finished run is read back with.Cleanup(remove=['*.vtk', 'd3plot0*'])Exactly those patterns, in every run directory, instead of the declared set.
Cleanup(remove=Cleanup.ALL)Everything except the protected files and
keep. A job directory also holds files nothing declared — copied-in inputs, solver scratch — so this deletes more than kunene knows about. Use it deliberately, and once withdry_run=Truefirst.
What you keep. keep is a list of glob patterns that always wins
over remove, so you subtract from the declared set rather than
enumerating what to delete:
Cleanup(keep=['d3plot']) # drop the state files, keep the first plot
The same argument exists on the solver actions themselves, next to the knowledge of what those files are. The action only declares it; the work area still decides when to delete:
DynaAnalysis(name='run', input_path='spring.k', keep=['d3plot', 'binout*'])
What is never deleted. actions_output.pkl, iter_variables.json,
jobs_index.json and status.json, whatever the patterns match. A
cleaned study is still a study: results_for, collect,
find_jobs and reuse_existing all keep working, since they read
the stored outputs and the index rather than the solver files.
WorkArea. The same argument works there, and is inherited by a work
area nested inside a SimulationIterator unless that work area sets its
own policy:
inner = WorkArea(wf, cleanup=Cleanup(keep=['d3plot*'])) # overrides
itr = SimulationIterator(outer_graph, cleanup=True)
Note that a WorkArea empties its directory at the start of every
run in any case; cleanup is about what the last run leaves behind,
and about work areas nested in a study.
Checking first. Cleanup(dry_run=True) logs what it would remove
and removes nothing. print_work_dir() marks the affected files, so
the predicted directory structure stays honest about what survives:
itr.print_work_dir()
SpringWorkFlow/ (results root)
├── status.json (run progress: current job, jobs done; ...)
├── jobs_index.json (job -> variable values and group labels; ...)
├── job_0/ (one directory per design evaluation)
│ ├── iter_variables.json (this design's variable values)
│ ├── actions_output.pkl (this design's action outputs)
│ ├── dyna_variables.json
│ ├── dyna_action_inp.k
│ ├── run_file.stdout
│ ├── run_file.stderr
│ ├── d3plot* (removed by cleanup)
│ └── d3hsp
└── job_1/ … job_N/
The mark is per pattern, not per file: with keep=['d3plot'] the
d3plot* line is marked even though the first plot survives.