Skip to content

reduce_drms

Implementation details of util/reduce_drm_sets.py.

reduce_drm_sets.py: reduce a list of simulation-ensembles to summary CSV files

usage:

  reduce_drm_sets.py [ -E ] [ -O outfile ] [ -j N ] [ -i indexfile] ENS [...]
where

ENS ... is a list of simulation directories ("ensembles"),

and:

  -O outfile gives a template (containing exactly two occurrences of %s) for
     file outputs.  This is optional - output will go to the ENS parent
     directory, according to Sandbox conventions, if not given.
     So, -O is normally not needed, but is good for debugging.
  -j N means to use N parallel workers to read files (default 1).
     Parallelism is vestigial; it slows processing on current hosts.
     If N = 0 or 1, no parallel workers are used: fastest for our
     small workload, and helpful for debugging.
  -i indexfile names a JSON index file (by convention, s_index.json) that 
     associates experiment names with parameter values for that experiment.
     This allows output of summary information that is labeled with the
     corresponding parameter values.
     (Giving -i is usually un-needed: if Sandbox conventions are used, this
     code automatically looks for s_index.json in the ENS parent directory)

also, importantly:

  -E means to glob-expand the given ENS into ENS/*/. In this case, s_index.json
     is sought within the given ENS, and outputs are put in ENS itself. That is,
     in this case, ENS is itself the parent directory.
     NOTE: Typically -E is used.

This program rolls up the summary information already recorded (for each ENS) in ENS/reduce_info.csv, and places the cumulative summary in a CSV file following the given filename template. It also tabulates some yield metrics and places in related CSV files. Overall:

  • reduce-info.csv: one-line file with overall yield and size information
  • reduce-yield.csv: same as below but with no ensemble name information
  • reduce-yield-all.json: one JSON object per ENS, with summary information as in reduce-yield-plus.csv (below), as well as some other information such as runtime.
  • reduce-yield-plus.csv: one line per ENS, with summary yield information, ENS name, and parameter information (if s_index.json exists). Like so:
  experiment,diam,contrast,tput,iwa,chars_earth_unique,detections_earth_all,detections_earth_unique,ensemble_size
  s_YX_D7.8_iwa44_C6.36e-11_tput0.22,7.8,6.36e-11,0.22,44,7.42,20.41,10.89,100
  s_YX_D7.4_iwa64_C4.27e-10_tput0.47,7.4,4.27e-10,0.47,64,6.85,21.02,11.51,100
  s_YX_D8.0_iwa66_C3.45e-11_tput0.13,8.0,3.45e-11,0.13,66,7.10,22.43,12.77,100
  s_YX_D9.0_iwa56_C5.86e-11_tput0.54,9.0,5.86e-11,0.54,56,11.61,33.51,18.11,100
  s_YX_D7.8_iwa50_C4.88e-10_tput0.45,7.8,4.88e-10,0.45,50,16.29,45.74,25.51,100
  s_YX_D8.0_iwa38_C5.68e-10_tput0.57,8.0,5.68e-10,0.57,38,19.88,51.44,28.32,100

(Note: the "experiment" column should have been labeled "scenario", according to our current naming conventions.)

This program supports nested Experiments. That is, if -E is given, and if the parent ENS contains only Experiments (.exp suffixes -- no .fam), an omnibus summary is generated (ENS/reduce-yield-plus.csv) that consolidates all sub-Experiments (ENS/.exp/reduce-yield-plus.csv). This allows "chunking" large Experiments into sub-Experiments, but still generating a single yield summary CSV. To support this, all Experiments (ENS and ENS/.exp) must contain their own s_index.json (with their portion of the whole). Upstream tooling must generate these files.

The typical use cases are as follows: - ENS list contains N basic Ensembles (single scenario yields) The parent ENS should end in .exp (s_index.json expected) or .fam. + reduce-info.csv contains the Ensemble sizing (N, and the sum of DRM counts within each Ensemble), and maximum yields. + reduce-yield-plus.csv contains N lines, each with average yields for one Ensemble, and the Ensemble name. If s_index.json exists (parent is an Experiment, and ends in .exp by convention), this file will have extra columns giving all parameters for that Ensemble. - ENS list contains N Experiments (all ending in .exp, no .fam). + reduce-info.csv is now summarizing summaries. It contains the number (N) of child Experiments, and the overall number of Ensembles underneath, and maximal yields (which are informative). + reduce-yield-plus.csv will contain yields for all Experiments below (typically many more than just N, the number of Experiments), together with their relevant parameter values. - ENS list contains mixed Ensemble collections (Experiments and Families, ending in .exp and .fam), for a total of N collections. + reduce-info.csv is now summarizing summaries, but that's OK. It contains the number (N) of child collections, and the overall number of Ensembles underneath, and maximal yields. In some cases, these maximal yields will be for completely unrelated scenarios, and so not very informative, but in other cases, they will be. + reduce-yield-plus.csv will contain N lines, one for each collection, with the collection name and maximal yield.

These cases are in fact all the same. In each case, this code is loading ENS/reduce-info.csv for each relevant ENS, and producing a one-line "maximal" summary, and a multi-line summary file of all these reduce-info's.

Typical usage

util/reduce_drm_sets.py -E sims/Yokohama_Extended.fam

EnsembleRun

Bases: object

Load and summarize an Ensemble.

Source code in util/reduce_drm_sets.py
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
class EnsembleRun(object):
    r'''Load and summarize an Ensemble.'''
    def __init__(self, f, sim_root):
        # allow creating a dummy object so that its properties may be queried
        self.summary = None # place-holder for later
        if f is None:
            self.name = 'dummy'
            self.Nens = 0
            self.info = {}
            return
        info = self.read_sim_summary(f, sim_root)
        # Case where 'experiment' is not in the summary (reduce-info.csv)
        # Some {FOO.exp,FOO.fam}/reduce-info.csv's do not have 'experiment'
        # defined (this was an un-noticed error). The clause below fixes this.
        if 'experiment' not in info or info['experiment'] == '':
            # print(f'No Experiment: fix with: util/{args.progname} -E {f}')
            info['experiment'] = os.path.basename(f)
        # set up object state
        self.name = f
        self.info = info
        try:
            self.Nens = info['ensemble_size']
        except KeyError:
            self.Nens = 0 # (can be empty)

    def convert_sim_summary(self, raw_props):
        # For each property that we want to convert: (key, converter, default_value)
        # (we don't use all of these now)
        # for 'experiment':
        # (1) we must strip the left-padding because of a 
        # hack that left-pads names with a single space to control how they
        # are ultimately displayed.  This was innocuous when names were just 
        # labels, but here they are meaningful identifiers
        # (2) If it is not present, we (later) skip the record because the Ensemble
        # is not index'able without a name - the '' signals this
        prop_map = [
            ('ensemble_size', lambda s: int(float(s)), 0), # allow '0.0'
            ('runtime', str, '2000-01-01_00:00'), # actually a date
            ('simtime', str, '2000-01-01_00:01'), # actually a date
            ('user', str, 'n/a'), 
            ('experiment', str.lstrip, ''), 
            ('detections_earth_all', float, 0.0),
            ('detections_earth_unique', float, 0.0),
            ('chars_earth_unique', float, 0.0),
            ('chars_earth_strict', float, 0.0),
            # target depletion (new 2025/06)
            ('targ_dep_slope_all', float, 0.0),
            ('targ_dep_t80_all', float, 0.0),
            # not used in the roll-up
            ('detections_unique_mean', float, 0.0),
            ('chars_unique_mean', float, 0.0), 
            ('chars_strict_mean', float, 0.0), 
            ]
        props = {}
        for key, converter, nullval in prop_map:
            props[key] = converter(raw_props.get(key, nullval))
        return props

    def read_sim_summary(self, d, sim_root):
        r'''Summarize one simulation directory (ensemble) into a dict.'''
        # file should exist: d was screened earlier to contain this file
        info_fn = os.path.join(d, 'reduce-info.csv')
        # record root of HTML summary (directory for "index.html")
        # (this may properly be an indexer function, but it fits here)
        # (nested Experiments: cannot just take URL as basename(d))
        # info_dir is the directory containing all the summarized Ensembles
        if d.startswith(sim_root + '/'):
            info_dir = d[(len(sim_root)+1):]
        else:
            # should not happen!
            info_dir = os.path.basename(d)
        if info_dir.endswith(('.fam', '.exp')):
            # if it's a .fam/.exp, index.html is at top
            base_url = info_dir
        else:
            # if it's a plain ensemble index.html is inside html/
            base_url = os.path.join(info_dir, 'html')
        # grab the summary data for d, if possible
        try:
            with open(info_fn) as f:
                info_items = csv.DictReader(f);
                info = self.convert_sim_summary(next(info_items)) # it is a 1-line csv
                # if clause catches (and excludes) placeholder reduce-info.csv's
                # that were created by the Dakota stub that generates scripts.
                # (they are purely artifacts and have no good information)
                if len(info) < 2:
                    # print(f'\tSkipping: {info_fn}')
                    # this will later exclude the record
                    info = dict()
                else:
                    info["url"] = base_url
        except IOError:
            # it did exist earlier, but things can happen
            info = dict()
        return info

    def extract_info(self):
        r'''Extract summary info about the Ensemble.'''
        return copy.deepcopy(self.info)

    def summarize(self, econo=True):
        r'''Find the summary of the Ensemble as a dictionary held within the object.

        If econo, delete the info and keep only the summary.'''
        # this dict holds reductions for the current sim
        summary = self.extract_info()
        # delete the base data if asked
        if econo:
            self.info = None
        # keep a reference in the object
        self.summary = summary
        # also return the summary-dictionary
        return summary

extract_info()

Extract summary info about the Ensemble.

Source code in util/reduce_drm_sets.py
278
279
280
def extract_info(self):
    r'''Extract summary info about the Ensemble.'''
    return copy.deepcopy(self.info)

read_sim_summary(d, sim_root)

Summarize one simulation directory (ensemble) into a dict.

Source code in util/reduce_drm_sets.py
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
def read_sim_summary(self, d, sim_root):
    r'''Summarize one simulation directory (ensemble) into a dict.'''
    # file should exist: d was screened earlier to contain this file
    info_fn = os.path.join(d, 'reduce-info.csv')
    # record root of HTML summary (directory for "index.html")
    # (this may properly be an indexer function, but it fits here)
    # (nested Experiments: cannot just take URL as basename(d))
    # info_dir is the directory containing all the summarized Ensembles
    if d.startswith(sim_root + '/'):
        info_dir = d[(len(sim_root)+1):]
    else:
        # should not happen!
        info_dir = os.path.basename(d)
    if info_dir.endswith(('.fam', '.exp')):
        # if it's a .fam/.exp, index.html is at top
        base_url = info_dir
    else:
        # if it's a plain ensemble index.html is inside html/
        base_url = os.path.join(info_dir, 'html')
    # grab the summary data for d, if possible
    try:
        with open(info_fn) as f:
            info_items = csv.DictReader(f);
            info = self.convert_sim_summary(next(info_items)) # it is a 1-line csv
            # if clause catches (and excludes) placeholder reduce-info.csv's
            # that were created by the Dakota stub that generates scripts.
            # (they are purely artifacts and have no good information)
            if len(info) < 2:
                # print(f'\tSkipping: {info_fn}')
                # this will later exclude the record
                info = dict()
            else:
                info["url"] = base_url
    except IOError:
        # it did exist earlier, but things can happen
        info = dict()
    return info

summarize(econo=True)

Find the summary of the Ensemble as a dictionary held within the object.

If econo, delete the info and keep only the summary.

Source code in util/reduce_drm_sets.py
282
283
284
285
286
287
288
289
290
291
292
293
294
def summarize(self, econo=True):
    r'''Find the summary of the Ensemble as a dictionary held within the object.

    If econo, delete the info and keep only the summary.'''
    # this dict holds reductions for the current sim
    summary = self.extract_info()
    # delete the base data if asked
    if econo:
        self.info = None
    # keep a reference in the object
    self.summary = summary
    # also return the summary-dictionary
    return summary

EnsembleSummary

Bases: object

Compute, store, and dump summary information for a set of ensembles.

Source code in util/reduce_drm_sets.py
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
class EnsembleSummary(object):
    r'''Compute, store, and dump summary information for a set of ensembles.'''
    def __init__(self, in_files, args, lazy=True):
        '''Load an ensemble of simulations.'''
        # load index file, if given
        self.load_index(args)
        # save some useful state
        self.args = args
        self.ens_files = args.ens_files
        self.Nens = len(self.ens_files) # = 0 if no ensembles

    def load_index(self, args):
        r'''Load index mapping scenario name to parameter values.

        Sets up:
          + self.index (containing parameter values, some may be vectors)
          + self.index_csv (same values, but with vectors expanded as indexed scalars)
        The latter is used for eventual output to summary .csv files.'''
        # load index file, if given
        if args.indexfile:
            print(f'{args.progname}: Loading index file.')
            with open(args.indexfile, 'r') as fp:
                index = json.load(fp)
        else:
            print(f'{args.progname}: Not using an index file.')
            index = []
        # copy index into a dict-of-dicts, EXP_NAME -> {param1:value1, param2:value2, ...}
        self.index = dict()
        # Remove these "internal bookkeeping" fields in s_index.json
        stop_fieldnames = set(('script_name', 'run_name'))
        for s in index:
            s1 = {k:v for k,v in s.items() if k not in stop_fieldnames}
            self.index[s['run_name']] = s1
        # also create a scalarized version of the index, for CSV output
        # this is the same mapping as "index", but it expands value-lists
        # into sequences of named scalars
        index_csv = dict()
        for scenario_name, d in self.index.items():
            index_csv[scenario_name] = OrderedDict()
            for param, value in d.items():
                if isinstance(value, list):
                    # expand the vector parameter "value" element-by-element
                    for inx, v1 in enumerate(value):
                        index_csv[scenario_name]['%s%d' % (param, inx+1)] = v1
                else:
                    index_csv[scenario_name][param] = value
        # save this also in the object
        self.index_csv = index_csv

    def load_and_reduce(self):
        r'''Load sim drm/spc, reduce each sim, accumulate summaries across sims.

        Each dict gives one summary statistic over that single sim.'''

        # In general, creates a pool of workers (separate unix processes)
        # (but: if jobs <= 1, it uses ordinary python map() and does no multiprocessing)
        with WorkerMap(self.args.jobs) as map_function:
            # map the load-and-reduce function over each file
            # reductions is a list of dicts containing summaries
            # (dict is empty if no valid summary existed)
            # py3: ensure the list is materialized
            reductions = list(map_function(partial(outer_load_and_reduce,
                                                   sim_root=self.args.sim_root,
                                                   verb=self.args.verbose),
                                      self.ens_files))
        # hacky fix for no-drm case
        # note: n_valid <= len(self.ens_files)
        n_valid = len([1 for r in reductions if len(r) > 0])
        if n_valid == 0:
            reductions = outer_load_and_reduce(None)
        # must save the original data for full-results tabular output
        self.reductions = [r for r in reductions if len(r) > 0]
        # re-group the above reductions across sims
        # result is a dict containing reduced data, stored as self.summary
        self.regroup_and_accum(self.reductions)

    def regroup_and_accum(self, reductions):
        r'''Accumulate various summaries across the ensemble.

        Nothing is returned: result is placed in the object state.'''

        # list of attributes to accumulate
        attrs = [
            'detections_earth_all', 'detections_earth_unique', 'detections_unique_mean',
            'chars_unique_mean', 'chars_earth_unique', 'chars_earth_strict',
            'ensemble_size', 
            ]

        # 1: flatten the reductions from [ens][attribute] to [attribute]
        # accum is a dictionary of lists
        accum = {}
        for attr in attrs:
            accum[attr] = []
            for r in reductions:
                accum[attr].append(r[attr])

        # prepare to get the latest simtime -- be somewhat robust if absent
        simtimes = [r['simtime'] for r in reductions if 'simtime' in r]
        simtimes.sort()

        # 2: take sum, means, max'es (whatever) of the various attributes
        #    not all computed are actually useful, or used later
        #    some QOIs can in principle have NaNs
        summary = {}
        #np.set_printoptions(precision=3) # for interactive debugging
        for attr in attrs:
            # suppress "empty slice" warnings
            with warnings.catch_warnings():
                warnings.simplefilter("ignore", category=RuntimeWarning)
                summary[attr + '_sum'] = np.sum(accum[attr], axis=0)
                try:
                    summary[attr + '_max'] = np.nanmax(accum[attr], axis=0)
                except ValueError:
                    summary[attr + '_max'] = np.nan # fails for empties
                summary[attr + '_mean'] = np.nanmean(accum[attr], axis=0)
                # ddof=1: we're estimating the mean separately, and want Nobs=1 => no std
                summary[attr + '_std']  = np.nanstd( accum[attr], axis=0, ddof=1)
                # number of non-NaN entries in each bin of the above averages (a vector)
                N_valid = np.sum(np.isfinite(accum[attr]), axis=0)
                summary[attr + '_nEns'] = N_valid
        # This info eventually lands in reduce-info.csv
        # Note: the info here pertains only to the top-level scenario for this run, 
        # not the child scenarios that have their own experiment names, etc., and
        # that are compiled into each line of reduce-yield-plus.csv
        extra_info = dict(
            user=os.environ['USER'],
            runtime=time.strftime("%Y-%m-%d_%H:%M"),
            simtime=simtimes[-1] if simtimes else '2000-01-01_00:00',
            # experiment: actually the "scenario" name
            # we only write the last component, not the full path (TBD)
            experiment=os.path.basename(args.expt_name_readable),
            experiment_size=len(reductions), # only already-reduced ensembles
            )
        summary.update(extra_info)
        # record this cross-ensemble summary in the object
        self.summary = summary


    def dump_results_worker(self, args, extension, first_fields, otype='csv', complain=True, extras=False):
        r'''Dump reduced data to files.

        Arguments:
          otype: string, contains "csv", "json", or both; defines output file type
          complain: bool; do we issue warnings for out-of-date s_index.json?
          extras: bool; set True to write all gathered scenario information
        '''

        # FIXME: JSON was added later and it shows. We should generate what-to-write,
        # and then write it to CSV, JSON, or both as a second step.
        # (See /dev/null below.)
        # But the present implementation is completely functional.

        if 'csv' in otype:
            fn = args.outfile % (extension, 'csv')
            print('\tDumping CSV to %s' % fn)
        else:
            # wasteful, yet so expedient
           fn = '/dev/null'
        # list of basic field-names in order they should be dumped
        saved_fields = [
            'chars_earth_unique',
            'detections_earth_all',
            'detections_earth_unique',
            # target depletion (new 2025/06)
            'targ_dep_slope_all',
            'targ_dep_t80_all',
            'ensemble_size',
            ]
        # take parameter-list, if any, from first param entry in self.index_csv
        has_index = len(self.index_csv) > 0
        if has_index:
            # any key would do - we take the first
            first_key = next(iter(self.index_csv))
            param_fields = list(self.index_csv[first_key].keys())
        else:
            param_fields = []

        # fields to dump includes everything
        all_fields = first_fields + param_fields + saved_fields
        if extras:
            # toss it all in
            extra_fields = list(set(self.reductions[0].keys()) - set(all_fields))
            all_fields.extend(extra_fields)
            saved_fields.extend(extra_fields)
        # record the final dumped data so CSV and JSON match
        d_full = []
        with open(fn, 'w') as csvfile:
            # OK to have extra fields in dict-for-row
            w = csv.DictWriter(csvfile, fieldnames=all_fields, extrasaction='ignore')
            w.writeheader()
            # dictionary mapping field -> value -- everything is a scalar here
            for r in self.reductions:
                d = {key:r[key] for key in saved_fields}
                d['experiment'] = r['experiment']
                # add in the varying parameters, if they were recorded
                if has_index:
                    scenario_name = r['experiment']
                    try:
                        scenario_params = self.index_csv[scenario_name]
                    except KeyError:
                        # happens if there are ensembles in the .exp dir, that are not
                        # in s_index.json
                        if complain:
                            print(f'{args.progname}: params for {scenario_name} not in s_index.json, is it up-to-date?')
                        # all fields (e.g., params of scenarios) will be unknowns
                        scenario_params = defaultdict(lambda: "?")
                    # add in all desired params to "d"
                    for f in param_fields:
                        d[f] = scenario_params[f]
                w.writerow(d)
                # save result for the JSON
                d_full.append(d)
        if 'csv' in otype:
            ensure_permissions(fn)
        # dump the JSON version
        if 'json' in otype:
            fn = args.outfile % (extension, 'json')
            print('\tDumping JSON to %s' % fn)
            with open(fn, 'w') as jsonfile:
                json.dump(nan2None(d_full), jsonfile, indent=2)
            ensure_permissions(fn)

    def dump_results(self, args):
        r'''Dump multi-line summary data to files

        Exports:
          - reduce-yield.csv:
                + chars, dets, Nens
                + parameter values, if index is available
          - reduce-yield-plus.csv: above, plus
                + scenario name (recorded as "experiment")
          This means that reduce-yield.csv is not in fact very useful.
          '''

        # only complain the first time
        self.dump_results_worker(args, 'yield', [], complain=True)
        self.dump_results_worker(args, 'yield-plus', ['experiment'], complain=False)
        self.dump_results_worker(args, 'yield-all', ['experiment'],
                                 otype='json', complain=False, extras=True)

    def dump_summary(self, args):
        r'''Dump overall, one-line summary data to a file: reduce-info.csv'''

        fn = args.outfile % ('info', 'csv')
        print('\tDumping to %s' % fn)
        # list of (field-name-as-output, field-name-here),
        # in order they should be dumped
        saved_field_map = [
            ('user', 'user'),
            ('runtime', 'runtime'),
            ('simtime', 'simtime'),
            ('experiment_size', 'experiment_size'),
            ('ensemble_size', 'ensemble_size_sum'),
            ('detections_earth_all', 'detections_earth_all_max'),
            ('detections_earth_unique', 'detections_earth_unique_max'),
            ('chars_earth_unique', 'chars_earth_unique_max'),
            ('chars_earth_strict', 'chars_earth_strict_max'),
            ('experiment', 'experiment'),
            ]
        with open(fn, 'w') as csvfile:
            w = csv.DictWriter(csvfile, fieldnames=[f for f,_ in saved_field_map])
            w.writeheader()
            # dictionary mapping field -> value -- everything is a scalar here
            d = {f_out:self.summary[f_have] for f_out, f_have in saved_field_map}
            w.writerow(d)
        ensure_permissions(fn)

    def dump_readme(self, args):
        r'''Dump a README document, if possible.

        Looks in the Scripts/ directory (that corresponds to the sims/... input
        that was given in the arguments), and tries to find a README type document
        for that ensemble-set. If found, the README is copied to either README.md
        or README.html in the sims/ directory.'''

        if not args.script_root:
            print(f'\tScript directory name unavailable. Continuing.')
            return
        # map is: src: dest, where ...
        #   src = README file name in Scripts/
        #   dest = README file name in sims/
        # the goal here is to have only 2 allowed file types
        # within sims/: text (as markdown), and HTML, so that
        # the html indexer does not have to try everything
        fn_map = {
            'README.md':  'README.md',
            'README.txt': 'README.md',
            'README':     'README.md',
            'README.html': 'README.html'
            }
        # filename surgery on the given outfile template
        # (we need the "naked" directory, not the template)
        outfile_dir = os.path.dirname(args.outfile % ('dummy', 'txt'))
        # attempt to find a README in Scripts/
        for fn_src, fn_dest in fn_map.items():
            fn_full = os.path.join(args.script_root, fn_src)
            if os.path.isfile(fn_full):
                fn_out = os.path.join(outfile_dir, fn_dest)
                print(f'\tDumping {fn_src} to {fn_out}')
                shutil.copyfile(fn_full, fn_out)
                ensure_permissions(fn_out)
                break
        else:
            print(f'\tNo README present. Continuing.')

    def dump_index(self, args):
        r'''Dump the index file, if possible.

        Looks in the Scripts/ directory (that corresponds to the sims/... input
        that was given in the arguments), and tries to find s_index.json
        for that ensemble-set. If found, it is copied to the sims/ directory.'''

        if not args.indexfile:
            if args.verbose:
                print(f'\tIndex file name unavailable. Continuing.')
            return False
        # can happen if you explicitly give the index file
        # not using Sandbox => don't just put it somewhere
        if not args.indexfile.startswith('Scripts/'):
            if args.verbose:
                print(f'\tIndex file {args.indexfile} not in "Scripts/". Continuing.')
            return False
        if not os.path.isfile(args.indexfile):
            print(f'\tIndex file {args.indexfile} no longer found. Continuing.')
            return False
        #
        fn_out = args.indexfile.replace('Scripts/', 'sims/', 1)
        print(f'\tDumping {args.indexfile} to {fn_out}')
        shutil.copyfile(args.indexfile, fn_out)
        ensure_permissions(fn_out)
        return True

__init__(in_files, args, lazy=True)

Load an ensemble of simulations.

Source code in util/reduce_drm_sets.py
312
313
314
315
316
317
318
319
def __init__(self, in_files, args, lazy=True):
    '''Load an ensemble of simulations.'''
    # load index file, if given
    self.load_index(args)
    # save some useful state
    self.args = args
    self.ens_files = args.ens_files
    self.Nens = len(self.ens_files) # = 0 if no ensembles

dump_index(args)

Dump the index file, if possible.

Looks in the Scripts/ directory (that corresponds to the sims/... input that was given in the arguments), and tries to find s_index.json for that ensemble-set. If found, it is copied to the sims/ directory.

Source code in util/reduce_drm_sets.py
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
def dump_index(self, args):
    r'''Dump the index file, if possible.

    Looks in the Scripts/ directory (that corresponds to the sims/... input
    that was given in the arguments), and tries to find s_index.json
    for that ensemble-set. If found, it is copied to the sims/ directory.'''

    if not args.indexfile:
        if args.verbose:
            print(f'\tIndex file name unavailable. Continuing.')
        return False
    # can happen if you explicitly give the index file
    # not using Sandbox => don't just put it somewhere
    if not args.indexfile.startswith('Scripts/'):
        if args.verbose:
            print(f'\tIndex file {args.indexfile} not in "Scripts/". Continuing.')
        return False
    if not os.path.isfile(args.indexfile):
        print(f'\tIndex file {args.indexfile} no longer found. Continuing.')
        return False
    #
    fn_out = args.indexfile.replace('Scripts/', 'sims/', 1)
    print(f'\tDumping {args.indexfile} to {fn_out}')
    shutil.copyfile(args.indexfile, fn_out)
    ensure_permissions(fn_out)
    return True

dump_readme(args)

Dump a README document, if possible.

Looks in the Scripts/ directory (that corresponds to the sims/... input that was given in the arguments), and tries to find a README type document for that ensemble-set. If found, the README is copied to either README.md or README.html in the sims/ directory.

Source code in util/reduce_drm_sets.py
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
def dump_readme(self, args):
    r'''Dump a README document, if possible.

    Looks in the Scripts/ directory (that corresponds to the sims/... input
    that was given in the arguments), and tries to find a README type document
    for that ensemble-set. If found, the README is copied to either README.md
    or README.html in the sims/ directory.'''

    if not args.script_root:
        print(f'\tScript directory name unavailable. Continuing.')
        return
    # map is: src: dest, where ...
    #   src = README file name in Scripts/
    #   dest = README file name in sims/
    # the goal here is to have only 2 allowed file types
    # within sims/: text (as markdown), and HTML, so that
    # the html indexer does not have to try everything
    fn_map = {
        'README.md':  'README.md',
        'README.txt': 'README.md',
        'README':     'README.md',
        'README.html': 'README.html'
        }
    # filename surgery on the given outfile template
    # (we need the "naked" directory, not the template)
    outfile_dir = os.path.dirname(args.outfile % ('dummy', 'txt'))
    # attempt to find a README in Scripts/
    for fn_src, fn_dest in fn_map.items():
        fn_full = os.path.join(args.script_root, fn_src)
        if os.path.isfile(fn_full):
            fn_out = os.path.join(outfile_dir, fn_dest)
            print(f'\tDumping {fn_src} to {fn_out}')
            shutil.copyfile(fn_full, fn_out)
            ensure_permissions(fn_out)
            break
    else:
        print(f'\tNo README present. Continuing.')

dump_results(args)

Dump multi-line summary data to files

Exports
  • reduce-yield.csv:
    • chars, dets, Nens
    • parameter values, if index is available
  • reduce-yield-plus.csv: above, plus
    • scenario name (recorded as "experiment") This means that reduce-yield.csv is not in fact very useful.
Source code in util/reduce_drm_sets.py
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
def dump_results(self, args):
    r'''Dump multi-line summary data to files

    Exports:
      - reduce-yield.csv:
            + chars, dets, Nens
            + parameter values, if index is available
      - reduce-yield-plus.csv: above, plus
            + scenario name (recorded as "experiment")
      This means that reduce-yield.csv is not in fact very useful.
      '''

    # only complain the first time
    self.dump_results_worker(args, 'yield', [], complain=True)
    self.dump_results_worker(args, 'yield-plus', ['experiment'], complain=False)
    self.dump_results_worker(args, 'yield-all', ['experiment'],
                             otype='json', complain=False, extras=True)

dump_results_worker(args, extension, first_fields, otype='csv', complain=True, extras=False)

Dump reduced data to files.

Parameters:

Name Type Description Default
otype

string, contains "csv", "json", or both; defines output file type

'csv'
complain

bool; do we issue warnings for out-of-date s_index.json?

True
extras

bool; set True to write all gathered scenario information

False
Source code in util/reduce_drm_sets.py
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
def dump_results_worker(self, args, extension, first_fields, otype='csv', complain=True, extras=False):
    r'''Dump reduced data to files.

    Arguments:
      otype: string, contains "csv", "json", or both; defines output file type
      complain: bool; do we issue warnings for out-of-date s_index.json?
      extras: bool; set True to write all gathered scenario information
    '''

    # FIXME: JSON was added later and it shows. We should generate what-to-write,
    # and then write it to CSV, JSON, or both as a second step.
    # (See /dev/null below.)
    # But the present implementation is completely functional.

    if 'csv' in otype:
        fn = args.outfile % (extension, 'csv')
        print('\tDumping CSV to %s' % fn)
    else:
        # wasteful, yet so expedient
       fn = '/dev/null'
    # list of basic field-names in order they should be dumped
    saved_fields = [
        'chars_earth_unique',
        'detections_earth_all',
        'detections_earth_unique',
        # target depletion (new 2025/06)
        'targ_dep_slope_all',
        'targ_dep_t80_all',
        'ensemble_size',
        ]
    # take parameter-list, if any, from first param entry in self.index_csv
    has_index = len(self.index_csv) > 0
    if has_index:
        # any key would do - we take the first
        first_key = next(iter(self.index_csv))
        param_fields = list(self.index_csv[first_key].keys())
    else:
        param_fields = []

    # fields to dump includes everything
    all_fields = first_fields + param_fields + saved_fields
    if extras:
        # toss it all in
        extra_fields = list(set(self.reductions[0].keys()) - set(all_fields))
        all_fields.extend(extra_fields)
        saved_fields.extend(extra_fields)
    # record the final dumped data so CSV and JSON match
    d_full = []
    with open(fn, 'w') as csvfile:
        # OK to have extra fields in dict-for-row
        w = csv.DictWriter(csvfile, fieldnames=all_fields, extrasaction='ignore')
        w.writeheader()
        # dictionary mapping field -> value -- everything is a scalar here
        for r in self.reductions:
            d = {key:r[key] for key in saved_fields}
            d['experiment'] = r['experiment']
            # add in the varying parameters, if they were recorded
            if has_index:
                scenario_name = r['experiment']
                try:
                    scenario_params = self.index_csv[scenario_name]
                except KeyError:
                    # happens if there are ensembles in the .exp dir, that are not
                    # in s_index.json
                    if complain:
                        print(f'{args.progname}: params for {scenario_name} not in s_index.json, is it up-to-date?')
                    # all fields (e.g., params of scenarios) will be unknowns
                    scenario_params = defaultdict(lambda: "?")
                # add in all desired params to "d"
                for f in param_fields:
                    d[f] = scenario_params[f]
            w.writerow(d)
            # save result for the JSON
            d_full.append(d)
    if 'csv' in otype:
        ensure_permissions(fn)
    # dump the JSON version
    if 'json' in otype:
        fn = args.outfile % (extension, 'json')
        print('\tDumping JSON to %s' % fn)
        with open(fn, 'w') as jsonfile:
            json.dump(nan2None(d_full), jsonfile, indent=2)
        ensure_permissions(fn)

dump_summary(args)

Dump overall, one-line summary data to a file: reduce-info.csv

Source code in util/reduce_drm_sets.py
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
def dump_summary(self, args):
    r'''Dump overall, one-line summary data to a file: reduce-info.csv'''

    fn = args.outfile % ('info', 'csv')
    print('\tDumping to %s' % fn)
    # list of (field-name-as-output, field-name-here),
    # in order they should be dumped
    saved_field_map = [
        ('user', 'user'),
        ('runtime', 'runtime'),
        ('simtime', 'simtime'),
        ('experiment_size', 'experiment_size'),
        ('ensemble_size', 'ensemble_size_sum'),
        ('detections_earth_all', 'detections_earth_all_max'),
        ('detections_earth_unique', 'detections_earth_unique_max'),
        ('chars_earth_unique', 'chars_earth_unique_max'),
        ('chars_earth_strict', 'chars_earth_strict_max'),
        ('experiment', 'experiment'),
        ]
    with open(fn, 'w') as csvfile:
        w = csv.DictWriter(csvfile, fieldnames=[f for f,_ in saved_field_map])
        w.writeheader()
        # dictionary mapping field -> value -- everything is a scalar here
        d = {f_out:self.summary[f_have] for f_out, f_have in saved_field_map}
        w.writerow(d)
    ensure_permissions(fn)

load_and_reduce()

Load sim drm/spc, reduce each sim, accumulate summaries across sims.

Each dict gives one summary statistic over that single sim.

Source code in util/reduce_drm_sets.py
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
def load_and_reduce(self):
    r'''Load sim drm/spc, reduce each sim, accumulate summaries across sims.

    Each dict gives one summary statistic over that single sim.'''

    # In general, creates a pool of workers (separate unix processes)
    # (but: if jobs <= 1, it uses ordinary python map() and does no multiprocessing)
    with WorkerMap(self.args.jobs) as map_function:
        # map the load-and-reduce function over each file
        # reductions is a list of dicts containing summaries
        # (dict is empty if no valid summary existed)
        # py3: ensure the list is materialized
        reductions = list(map_function(partial(outer_load_and_reduce,
                                               sim_root=self.args.sim_root,
                                               verb=self.args.verbose),
                                  self.ens_files))
    # hacky fix for no-drm case
    # note: n_valid <= len(self.ens_files)
    n_valid = len([1 for r in reductions if len(r) > 0])
    if n_valid == 0:
        reductions = outer_load_and_reduce(None)
    # must save the original data for full-results tabular output
    self.reductions = [r for r in reductions if len(r) > 0]
    # re-group the above reductions across sims
    # result is a dict containing reduced data, stored as self.summary
    self.regroup_and_accum(self.reductions)

load_index(args)

Load index mapping scenario name to parameter values.

Sets up
  • self.index (containing parameter values, some may be vectors)
  • self.index_csv (same values, but with vectors expanded as indexed scalars)

The latter is used for eventual output to summary .csv files.

Source code in util/reduce_drm_sets.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
def load_index(self, args):
    r'''Load index mapping scenario name to parameter values.

    Sets up:
      + self.index (containing parameter values, some may be vectors)
      + self.index_csv (same values, but with vectors expanded as indexed scalars)
    The latter is used for eventual output to summary .csv files.'''
    # load index file, if given
    if args.indexfile:
        print(f'{args.progname}: Loading index file.')
        with open(args.indexfile, 'r') as fp:
            index = json.load(fp)
    else:
        print(f'{args.progname}: Not using an index file.')
        index = []
    # copy index into a dict-of-dicts, EXP_NAME -> {param1:value1, param2:value2, ...}
    self.index = dict()
    # Remove these "internal bookkeeping" fields in s_index.json
    stop_fieldnames = set(('script_name', 'run_name'))
    for s in index:
        s1 = {k:v for k,v in s.items() if k not in stop_fieldnames}
        self.index[s['run_name']] = s1
    # also create a scalarized version of the index, for CSV output
    # this is the same mapping as "index", but it expands value-lists
    # into sequences of named scalars
    index_csv = dict()
    for scenario_name, d in self.index.items():
        index_csv[scenario_name] = OrderedDict()
        for param, value in d.items():
            if isinstance(value, list):
                # expand the vector parameter "value" element-by-element
                for inx, v1 in enumerate(value):
                    index_csv[scenario_name]['%s%d' % (param, inx+1)] = v1
            else:
                index_csv[scenario_name][param] = value
    # save this also in the object
    self.index_csv = index_csv

regroup_and_accum(reductions)

Accumulate various summaries across the ensemble.

Nothing is returned: result is placed in the object state.

Source code in util/reduce_drm_sets.py
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
def regroup_and_accum(self, reductions):
    r'''Accumulate various summaries across the ensemble.

    Nothing is returned: result is placed in the object state.'''

    # list of attributes to accumulate
    attrs = [
        'detections_earth_all', 'detections_earth_unique', 'detections_unique_mean',
        'chars_unique_mean', 'chars_earth_unique', 'chars_earth_strict',
        'ensemble_size', 
        ]

    # 1: flatten the reductions from [ens][attribute] to [attribute]
    # accum is a dictionary of lists
    accum = {}
    for attr in attrs:
        accum[attr] = []
        for r in reductions:
            accum[attr].append(r[attr])

    # prepare to get the latest simtime -- be somewhat robust if absent
    simtimes = [r['simtime'] for r in reductions if 'simtime' in r]
    simtimes.sort()

    # 2: take sum, means, max'es (whatever) of the various attributes
    #    not all computed are actually useful, or used later
    #    some QOIs can in principle have NaNs
    summary = {}
    #np.set_printoptions(precision=3) # for interactive debugging
    for attr in attrs:
        # suppress "empty slice" warnings
        with warnings.catch_warnings():
            warnings.simplefilter("ignore", category=RuntimeWarning)
            summary[attr + '_sum'] = np.sum(accum[attr], axis=0)
            try:
                summary[attr + '_max'] = np.nanmax(accum[attr], axis=0)
            except ValueError:
                summary[attr + '_max'] = np.nan # fails for empties
            summary[attr + '_mean'] = np.nanmean(accum[attr], axis=0)
            # ddof=1: we're estimating the mean separately, and want Nobs=1 => no std
            summary[attr + '_std']  = np.nanstd( accum[attr], axis=0, ddof=1)
            # number of non-NaN entries in each bin of the above averages (a vector)
            N_valid = np.sum(np.isfinite(accum[attr]), axis=0)
            summary[attr + '_nEns'] = N_valid
    # This info eventually lands in reduce-info.csv
    # Note: the info here pertains only to the top-level scenario for this run, 
    # not the child scenarios that have their own experiment names, etc., and
    # that are compiled into each line of reduce-yield-plus.csv
    extra_info = dict(
        user=os.environ['USER'],
        runtime=time.strftime("%Y-%m-%d_%H:%M"),
        simtime=simtimes[-1] if simtimes else '2000-01-01_00:00',
        # experiment: actually the "scenario" name
        # we only write the last component, not the full path (TBD)
        experiment=os.path.basename(args.expt_name_readable),
        experiment_size=len(reductions), # only already-reduced ensembles
        )
    summary.update(extra_info)
    # record this cross-ensemble summary in the object
    self.summary = summary

WorkerMap

Bases: object

Abstracts the multiprocessing worker-pool; switches to no workers if jobs <= 1.

This allows you to go back to ordinary single-job processing by setting the number of jobs to 1.

Source code in util/reduce_drm_sets.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
class WorkerMap(object):
    r'''Abstracts the multiprocessing worker-pool; switches to no workers if jobs <= 1.

    This allows you to go back to ordinary single-job processing by setting the 
    number of jobs to 1.'''
    def __init__(self, jobs):
        self.jobs = jobs
        if jobs <= 1:
            # no worker pool: just use this process
            self.pool = None
            # map function is the python map() builtin
            self.map_function = map
        else:
            # the multiprocessing pool-of-workers
            self.pool = mproc.Pool(processes=jobs)
            # the map function that comes with the above
            self.map_function = self.pool.map
    def __enter__(self):
        return self.map_function
    def __exit__(self, type, value, traceback):
        if self.pool is not None:
            self.pool.terminate()

ensure_permissions(fn)

Ensure correct permissions on the named data file.
We use rw-rw-r-- = 664 (octal), to allow group-write.

Source code in util/reduce_drm_sets.py
151
152
153
154
155
156
157
def ensure_permissions(fn):
    r'''Ensure correct permissions on the named data file.  
    We use rw-rw-r-- = 664 (octal), to allow group-write.'''
    try:
        os.chmod(fn, 0o664)
    except OSError:
        pass # e.g., don't own the file

expand_infile_arg(args)

Expand args.infile into a list of scenarios

Assumes args.infile has at least one member.

Source code in util/reduce_drm_sets.py
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
def expand_infile_arg(args):
    '''Expand args.infile into a list of scenarios

    Assumes args.infile has at least one member.'''
    # list of ensemble directories: our main goal
    ens_files = []
    # is this nested Experiment (foo.exp/{bar,baz,brat}.exp)?
    # (if so, we will descend into its child experiments)
    inner_experiment = False
    if args.expand:
        infiles = args.infile
        if args.infile[0].endswith('.exp'):
            # look for possible experiment-of-experiments
            # Note: not using glob.glob() because of possible efficiency issues
            # with large scenario counts - only using os.scandir() here and below
            inner_exps = []
            found_fam = False
            for entry in os.scandir(args.infile[0]):
                if entry.path.endswith('.fam'):
                    # cannot be an exp-of-exp's
                    found_fam = True
                    break
                elif entry.path.endswith('.exp'):
                    inner_exps.append(entry.path)
                else:
                    pass # don't care about other dirs
            # if Nested Experiment, replace infile X.exp with X.exp/*.exp
            if inner_exps and not found_fam:
                inner_experiment = True
                infiles = inner_exps
                print(f"{args.progname}: Found {len(infiles)} sub-experiment(s).")
        # expand into: [d/reduce-info.csv for d in infiles if is_dir(d)]
        # one-liner is possible, but want to allow granular operation timing
        for infile1 in infiles:
            if not os.path.isdir(infile1):
                print(f"{args.progname}: Named directory {infile1} not present, skipping.")
                continue
            for entry in os.scandir(infile1):
                if entry.is_dir():
                    if os.path.isfile(os.path.join(entry.path, 'reduce-info.csv')):
                        ens_files.append(entry.path)
    else:
        # filter: only retain input directories (members of args.infile) with reduced DRM-sets
        for f in args.infile:
            if os.path.isfile(os.path.join(f, 'reduce-info.csv')):
                ens_files.append(f)
    # one of the scenarios we're summarizing - useful later
    if not ens_files:
        args.ens1 = ''
    elif inner_experiment:
        # need the enclosing experiment
        args.ens1 = os.path.dirname(os.path.normpath(ens_files[0]))
    else:
        args.ens1 = os.path.normpath(ens_files[0])
    return ens_files

nan2None(obj)

Convert NaNs to None for JSON encoding

Source code in util/reduce_drm_sets.py
140
141
142
143
144
145
146
147
148
def nan2None(obj):
    r'''Convert NaNs to None for JSON encoding'''
    if isinstance(obj, dict):
        return {k:nan2None(v) for k,v in obj.items()}
    elif isinstance(obj, list):
        return [nan2None(v) for v in obj]
    elif isinstance(obj, float) and np.isnan(obj):
        return None
    return obj

outer_load_and_reduce(f, sim_root='', verb=0)

Load a sim and summarize it into a dict.

This must be present at the outer scope of the file so it can be loaded by a separate process that is created by the multiprocessing module.

Source code in util/reduce_drm_sets.py
298
299
300
301
302
303
304
305
306
307
def outer_load_and_reduce(f, sim_root='', verb=0):
    r'''Load a sim and summarize it into a dict.

    This must be present at the outer scope of the file so it can be loaded
    by a separate process that is created by the multiprocessing module.'''
    if verb > 0:
        print('Processing <%s> in pid #%d' % (f, os.getpid()))
    ensemble = EnsembleRun(f, sim_root)
    # dictionary for this single Ensemble
    return ensemble.summarize()