• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

TRI-AMDD / mpet / 8476080312

29 Mar 2024 01:46AM UTC coverage: 55.461% (-7.2%) from 62.648%
8476080312

Pull #126

github

d-cogswell
Adds a benchmark section to docs.
Pull Request #126: v1.0.0

2351 of 4239 relevant lines covered (55.46%)

2.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

87.08
/mpet/config/configuration.py
1
"""
2
This module provides functions for various data format exchanges:
3
 - config files (on disk) <--> dictionaries of parameters (in memory)
4
 - dictionaries of parameters (in memory) <--> dictionaries of parameters ('pickled' on disk)
5

6
It also has various other functions used in processes for things such as generating
7
distributions from input means and standard deviations.
8
"""
9
import os
4✔
10
import pickle
4✔
11

12
import numpy as np
4✔
13

14
from mpet.config import constants
4✔
15
from mpet.config.derived_values import DerivedValues
4✔
16
from mpet.config.parameterset import ParameterSet
4✔
17

18

19
class Config:
4✔
20
    def __init__(self, paramfile='params.cfg', from_dicts=False):
4✔
21
        """
22
        Hold values from system and electrode configuration files, as well as
23
        derived values. When initializing a new Config object, the invididual
24
        particle distributions are generated and any non-dimensionalization is
25
        performed *in-place*.
26

27
        The actual parameters are stored in up to four separate dictionary-like objects:
28

29
        * ``D_s`` holds the system config
30
        * ``D_c`` holds the cathode config
31
        * ``D_a`` holds the anode config (only if anode is simulated)
32
        * ``derived_values`` holds the values that can be derived from (a combination of) the other
33
          configs
34

35
        All parameter values can be accessed directly from the Config object
36
        with the ``[]`` operator, like one would access values from a dictionary.
37
        For parameters that are defined for every individual particle, the values for
38
        all particles are returned, i.e. an array with shape ``(Nvol, Npart)``.
39
        See :meth:`__getitem__`
40
        for example usage of the ``[]`` operator.
41

42
        Note that if needed, the underlying dictionaries can be accessed directly,
43
        e.g. ``config.D_s``.
44

45
        :param str paramfile: Path to .cfg file on disk, or folder with dictionaries
46
            on disk if from_dicts=True
47
        :param bool from_dicts: Whether to read existing config dicts from disk.
48
            Instead of using from_dicts=True, consider using the Config.from_dicts function instead
49

50
        :return: Config object
51

52
        Examples:
53

54
        To read the config from a set of .cfg files:
55

56
        >>> from mpet.config import Config
57
        >>> config = Config('configs/params_system.cfg')
58

59
        To create config from a previous run of MPET
60
        (See also :meth:`from_dicts`):
61

62
        >>> from mpet.config import Config
63
        >>> config = Config.from_dicts('/path/to/previous/run/sim_output')
64

65
        .. make sure to document methods related to [] operator
66
        .. automethod:: __getitem__
67
        .. automethod:: __setitem__
68
        .. automethod:: __delitem__
69
        """
70
        # initialize class to calculate and hold derived values
71
        self.derived_values = DerivedValues()
4✔
72
        # Parameters defined per particle in an (Nvol, Npart) array.
73
        # initially this list is empty. When the individual particle values
74
        # are calculated, the list is populated.
75
        self.params_per_particle = []
4✔
76

77
        if from_dicts:
4✔
78
            # read existing dictionaries instead of parameter file
79
            # paramfile is now folder with input dicts
80
            self.path = os.path.normpath(paramfile)
×
81
            self._init_from_dicts()
×
82
        else:
83
            # store path to config file
84
            self.path = os.path.dirname(paramfile)
4✔
85
            self._init_from_cfg(paramfile)
4✔
86

87
    @classmethod
4✔
88
    def from_dicts(cls, path):
4✔
89
        """
90
        Create a config instance from a set of dictionaries on disk, instead
91
        of from config files.
92

93
        :param str path: folder containing previously-saved config dicts
94

95
        :return: Config object
96

97
        Example usage:
98

99
        >>> from mpet.config import Config
100
        >>> config = Config.from_dicts('/path/to/previous/run/sim_output')
101
        """
102
        return cls(path, from_dicts=True)
×
103

104
    def _init_from_dicts(self):
4✔
105
        """
106
        Initialize configuration from a set of dictionaries on disk, generated
107
        from a previous run. This method should only be called from the
108
        ``__init__`` of :class:`Config`.
109
        """
110
        # create empty system parameter set
111
        self.D_s = ParameterSet(None, 'system', self.path)
×
112
        # set which electrodes there are based on which dict files exist
113
        trodes = ['c']
×
114
        if os.path.isfile(os.path.join(self.path, 'input_dict_anode.p')):
×
115
            trodes.append('a')
×
116
        self['trodes'] = trodes
×
117
        # create empty electrode parametersets
118
        self.D_c = ParameterSet(None, 'electrode', self.path)
×
119
        if 'a' in self['trodes']:
×
120
            self.D_a = ParameterSet(None, 'electrode', self.path)
×
121
        else:
122
            self.D_a = None
×
123
        # now populate the dicts
124
        self.read(self.path, full=True)
×
125
        self.params_per_particle = list(constants.PARAMS_PARTICLE.keys())
×
126
        self.config_processed = True
×
127

128
    def _init_from_cfg(self, paramfile):
4✔
129
        """
130
        Initialize configuration from a set of .cfg files on disk.
131
        This method should only be called from the
132
        ``__init__`` of :class:`Config`.
133

134
        :param str paramfile: Path to system parameter .cfg file
135
        """
136
        # load system parameters file
137
        self.D_s = ParameterSet(paramfile, 'system', self.path)
4✔
138
        # the anode and separator are optional: only if there are volumes to simulate
139
        trodes = ['c']
4✔
140
        if self.D_s['Nvol_a'] > 0:
4✔
141
            trodes.append('a')
4✔
142
        self['trodes'] = trodes
4✔
143

144
        # load electrode parameter file(s)
145
        self.paramfiles = {}
4✔
146

147
        cathode_paramfile = self.D_s['cathode']
4✔
148
        if not os.path.isabs(cathode_paramfile):
4✔
149
            cathode_paramfile = os.path.join(self.path, cathode_paramfile)
4✔
150
            self.paramfiles['c'] = cathode_paramfile
4✔
151
        self.D_c = ParameterSet(cathode_paramfile, 'electrode', self.path)
4✔
152

153
        if 'a' in self['trodes']:
4✔
154
            anode_paramfile = self.D_s['anode']
4✔
155
            if not os.path.isabs(anode_paramfile):
4✔
156
                anode_paramfile = os.path.join(self.path, anode_paramfile)
4✔
157
            self.paramfiles['a'] = anode_paramfile
4✔
158
            self.D_a = ParameterSet(anode_paramfile, 'electrode', self.path)
4✔
159
        else:
160
            self.D_a = None
4✔
161

162
        # set defaults and scale values that should be non-dim
163
        self.config_processed = False
4✔
164
        # either process the config, or read already processed config from disk
165
        prevDir = self['prevDir']
4✔
166
        if prevDir and prevDir != 'false':
4✔
167
            if not os.path.isabs(prevDir):
4✔
168
                # assume it is relative to the input parameter files
169
                self['prevDir'] = os.path.normpath(os.path.join(self.path, prevDir))
4✔
170
            self._process_config(self['prevDir'])
4✔
171
        else:
172
            self._process_config()
4✔
173

174
        self._verify_config()
4✔
175

176
    def _retrieve_config(self, items):
4✔
177
        """
178
        Select system or electrode config based on reqested parameter(s) and
179
        return the parameter and selected electrode
180

181
        :param str/tuple items: tuple of (electrode, item) to retrieve `item` from
182
            `electrode` config, or a single string to retrieve item from system config.
183
            `electrode` must be one of a, c.
184

185
        :return: config subset (system/chosen electrode/individual particle in electrode),
186
            item, electrode (None if system config)
187
        """
188
        if isinstance(items, tuple):
4✔
189
            # electrode config
190
            try:
4✔
191
                trode, item = items
4✔
192
            except ValueError:
×
193
                raise ValueError(f'Reading from electrode config requires two arguments, but '
×
194
                                 f'got {len(items)}')
195
            # select correct config
196
            if trode == 'a':
4✔
197
                assert self.D_a is not None, 'Anode parameter requested but ' \
4✔
198
                                             'anode is not simulated'
199
                d = self.D_a
4✔
200
            elif trode == 'c':
4✔
201
                d = self.D_c
4✔
202
            else:
203
                raise ValueError(f'Provided electrode must be a or c, got {trode}')
×
204
        else:
205
            # system config
206
            trode = None
4✔
207
            item = items
4✔
208
            d = self.D_s
4✔
209

210
        # select individual particle settings if requested
211
        if item in self.params_per_particle:
4✔
212
            assert trode is not None, 'Particle-specific parameter requested but ' \
4✔
213
                                      'electrode not specified'
214
            d = self[trode, 'indvPart']
4✔
215
        return d, item, trode
4✔
216

217
    def write(self, folder=None, filenamebase='input_dict'):
4✔
218
        """
219
        Write config to disk in pickled format.
220

221
        :param str folder: Folder in which to store the config (default: current folder)
222
        :param str filenamebase: prefix of filenames. These are appended with _system,
223
            _cathode, _anode, and _derived_values to create four config dicts on disk.
224
        """
225
        if folder:
4✔
226
            filenamebase = os.path.join(folder, filenamebase)
4✔
227

228
        # system, derived values, cathode, optionally anode
229
        dicts = {'system': self.D_s.params, 'derived_values': self.derived_values.values,
4✔
230
                 'cathode': self.D_c.params}
231
        if 'a' in self['trodes']:
4✔
232
            dicts['anode'] = self.D_a.params
4✔
233

234
        for section, d in dicts.items():
4✔
235
            with open(f'{filenamebase}_{section}.p', 'wb') as f:
4✔
236
                pickle.dump(d, f)
4✔
237

238
    def read(self, folder=None, filenamebase='input_dict', full=False):
4✔
239
        """
240
        Read previously processed config from disk. This also sets the numpy random seed
241
        if enabled in the config.
242

243
        :param str folder: Folder from which to read the config (default: current folder)
244
        :param str filenamebase: prefix of filenames. These are appended with _system,
245
            _cathode, _anode, and _derived_values to read the four config dicts from disk
246
        :param bool full: If true, all values from the dictionaries on disk are read.
247
            If false, only the generated particle distributions are read from the config dicts,
248
            i.e. the ``psd_*`` and ``G`` values in the system config, and the ``indvPart``
249
            section of the electrode configs
250
        """
251

252
        if folder:
4✔
253
            filenamebase = os.path.join(folder, filenamebase)
4✔
254

255
        # system, derived values, cathode, optionally anode
256
        sections = ['system', 'derived_values', 'cathode']
4✔
257
        if 'a' in self['trodes']:
4✔
258
            sections.append('anode')
×
259

260
        for section in sections:
4✔
261
            with open(f'{filenamebase}_{section}.p', 'rb') as f:
4✔
262
                try:
4✔
263
                    d = pickle.load(f)
4✔
264
                except UnicodeDecodeError:
×
265
                    d = pickle.load(f, encoding='latin1')
×
266

267
            if full:
4✔
268
                # update all config
269
                if section == 'system':
×
270
                    self.D_s.params = d
×
271
                elif section == 'derived_values':
×
272
                    self.derived_values.values = d
×
273
                elif section == 'cathode':
×
274
                    self.D_c.params = d
×
275
                elif section == 'anode':
×
276
                    self.D_a.params = d
×
277
                else:
278
                    raise Exception(f'Unknown section: {section}')
×
279
            else:
280
                # only update generated distributions
281
                if section == 'system':
4✔
282
                    for key in ['psd_num', 'psd_len', 'psd_area', 'psd_vol',
4✔
283
                                'psd_vol_FracVol', 'G']:
284
                        self[key] = d[key]
4✔
285
                elif section in ['anode', 'cathode']:
4✔
286
                    trode = section[0]
4✔
287
                    self[trode, 'indvPart'] = d['indvPart']
4✔
288

289
        # make sure to set the numpy random seed, as is usually done in process_config
290
        if self['randomSeed']:
4✔
291
            np.random.seed(self.D_s['seed'])
4✔
292

293
        self.params_per_particle = list(constants.PARAMS_PARTICLE.keys())
4✔
294
        self.config_processed = True
4✔
295

296
    def __getitem__(self, items):
4✔
297
        """
298
        ``__getitem__`` is the ``[]`` operator. It is used to retrieve the value of a parameter.
299
        If the parameter is found in the available derived values, it is extracted from there.
300
        Else, it is read from the config files.
301
        :meth:`_retrieve_config` is called to automatically
302
        selected the right config file. If the requested parameter does not exist,
303
        an ``UnknownParameterError`` is raised.
304

305
        :param str/tuple items: tuple of (electrode, item) to retrieve `item` from
306
            `electrode` config, or a single string to retrieve item from system config.
307
            `electrode` must be one of a, c.
308

309
        :return: parameter value
310

311
        Example usage:
312

313
        Extract the profileType parameter from the system config:
314

315
        >>> config['profileType']
316
        'CC'
317

318
        To access an electrode parameter, add 'c' for cathode or 'a' for anode as first argument.
319
        The requested parameter should be the second argument. To read the type parameter from the
320
        cathode config:
321

322
        >>> config['c', 'type']
323
        'ACR'
324

325
        The same parameter for the anode:
326

327
        >>> config['a', 'type']
328
        'CHR'
329

330
        Note that if the anode is not simulated, trying to access an anode parameter will result
331
        in an error:
332

333
        >>> config['a', 'type']  # from a config where Nvol_a = 0
334
        AssertionError: Anode parameter requested but anode is not simulated
335

336
        A parameter defined for each particle, delta_L:
337

338
        >>> config['c', 'delta_L']
339
        array([[10.1, 10.1],
340
               [10. , 10. ],
341
               [10.1,  9.9],
342
               [10.1,  9.9],
343
               [ 9.9, 10.2],
344
               [ 9.9, 10.2],
345
               [10.3, 10. ],
346
               [10. , 10.1],
347
               [10.1, 10.1],
348
               [ 9.9,  9.9]])
349

350
        Finally, derived values are handled transparently. The csmax parameter from
351
        both the cathode and anode:
352

353
        >>> config['c', 'csmax']
354
        22904.35071404849
355
        >>> config['a', 'csmax']
356
        28229.8239787446
357
        """
358
        # select config
359
        d, item, trode = self._retrieve_config(items)
4✔
360

361
        # check if the item is a derived value
362
        if item in self.derived_values.available_values:
4✔
363
            value = self.derived_values[self, item, trode]
4✔
364
        else:
365
            # not a derived value, can be read from config
366
            # raises UnknownParameterError if not found
367
            value = d[item]
4✔
368

369
        return value
4✔
370

371
    def __setitem__(self, items, value):
4✔
372
        """
373
        ``__setitem__`` is the ``[]`` operator when used in an assignment, e.g.
374
        ``config['parameter'] = 2``.
375
        It can be used to store a parameter in one of the config dicts. The dictionary is selected
376
        automatically in the same way as ``__getitem__`` does.
377

378
        :param str/tuple items: tuple of (electrode, item) to retrieve `item` from
379
            `electrode` config, or a single string to retrieve item from system config.
380
            `electrode` must be one of a, c.
381
        """
382
        # select config, ignore returned trode value as we don't need it
383
        d, item, _ = self._retrieve_config(items)
4✔
384
        # make sure to update derived value if that is where the value originally came from
385
        if item in self.derived_values.available_values:
4✔
386
            self.derived_values.values[item] = value
4✔
387
        else:
388
            d[item] = value
4✔
389

390
    def __delitem__(self, items):
4✔
391
        """
392
        ``__delitem__`` is the ``[]`` operator when used to delete a value,
393
        e.g. ``del config['parameter']``. The specified item is automatically deleted from
394
        the config dictionary where it was originally stored.
395

396
        :param str/tuple items: tuple of (electrode, item) to retrieve `item` from
397
            `electrode` config, or a single string to retrieve item from system config.
398
            `electrode` must be one of a, c.
399
        """
400
        # select config, ignore returned trode value as we don't need it
401
        d, item, _ = self._retrieve_config(items)
×
402

403
        del d[item]
×
404

405
    def _process_config(self, prevDir=None):
4✔
406
        """
407
        Process raw config after loading files from disk. This can only be done once per
408
        instance of ``Config``, as parameters are scaled in-place. Attempting to run it a
409
        second time results in an error. The processing consists of several steps:
410

411
        #. Set numpy random seed (if enabled in config)
412
        #. Set default values
413
        #. Scale to non-dimensional values
414
        #. Parse current/voltage segments
415
        #. Either generate particle distributions or load from previous run
416
        #. Create simulation times
417

418
        :param bool prevDir: if True, load particle distributions from previous run,
419
            otherwise generate them
420
        """
421
        if self.config_processed:
4✔
422
            raise Exception('The config can be processed only once as values are scaled in-place')
×
423
        self.config_processed = True
4✔
424

425
        # ensure paths are absolute
426
        self._make_paths_absolute()
4✔
427

428
        # set the random seed (do this before generating any random distributions)
429
        if self.D_s['randomSeed']:
4✔
430
            np.random.seed(self.D_s['seed'])
4✔
431

432
        # set default 1C_current_density
433
        limtrode = self['limtrode']
4✔
434
        theoretical_1C_current = self[limtrode, 'cap'] / 3600.  # A/m^2
4✔
435
        param = '1C_current_density'
4✔
436
        if self[param] is None:
4✔
437
            # set to theoretical value
438
            self[param] = theoretical_1C_current
4✔
439

440
        # use custom concentration when using solid electrolyte model
441
        if self['elyteModelType'] == 'solid':
4✔
442
            self['c0'] = self['c0']
4✔
443

444
        # apply scalings
445
        self._scale_system_parameters(theoretical_1C_current)
4✔
446
        self._scale_electrode_parameters()  # includes separator
4✔
447
        # reference voltage, should only be calculated after scaling of system and trode parameters
448
        Vref = self['c', 'phiRef']
4✔
449
        if 'a' in self['trodes']:
4✔
450
            Vref -= self['a', 'phiRef']
4✔
451
        self._scale_macroscopic_parameters(Vref)
4✔
452
        self._scale_current_voltage_segments(theoretical_1C_current, Vref)
4✔
453

454
        if prevDir:
4✔
455
            # load particle distrubtions etc. from previous run
456
            self.read(prevDir, full=False)
4✔
457
            # Set params per particle as would be done in _invdPart when generating distributions
458
            self.params_per_particle = list(constants.PARAMS_PARTICLE.keys())
4✔
459
        else:
460
            # particle distributions
461
            self._distr_part()
4✔
462
            # Gibss free energy, must be done after distr_part
463
            self._G()
4✔
464
            # Electrode parameters that depend on invidividual particle
465
            self._indvPart()
4✔
466

467
        self._create_times()
4✔
468

469
    def _create_times(self):
4✔
470
        """
471

472
        """
473
        # The list of reporting times excludes the first index (zero, which is implied)
474
        if not self["times"]:
4✔
475
            self["times"] = list(np.linspace(0, self["tend"], self["tsteps"] + 1))[1:]
4✔
476

477
    def _scale_system_parameters(self, theoretical_1C_current):
4✔
478
        """
479
        Scale system parameters to non-dimensional values. This method should be called only once,
480
        from :meth:`_process_config`.
481
        """
482
        # non-dimensional scalings
483
        self['T'] = self['T'] / constants.T_ref
4✔
484
        self['Rser'] = self['Rser'] / self['Rser_ref']
4✔
485
        if self['Dp'] is not None:
4✔
486
            self['Dp'] = self['Dp'] / self['D_ref']
4✔
487
        if self['Dm'] is not None:
4✔
488
            self['Dm'] = self['Dm'] / self['D_ref']
4✔
489
        self['c0'] = self['c0'] / constants.c_ref
4✔
490
        self['phi_cathode'] = 0.  # TODO: why is this defined if always 0?
4✔
491
        self['currset'] = self['currset'] / (theoretical_1C_current * self['curr_ref'])
4✔
492
        if self['power'] is not None:
4✔
493
            self['power'] = self['power'] / (self['power_ref'])
4✔
494
        self['k0_foil'] = self['k0_foil'] / (self['1C_current_density'] * self['curr_ref'])
4✔
495
        self['Rfilm_foil'] = self['Rfilm_foil'] / self['Rser_ref']
4✔
496

497
        if self['elyteModelType'] == 'solid':
4✔
498
            self['cmax'] = self['cmax'] / constants.c_ref
4✔
499

500
        if self['simInterface_a'] or self['simInterface_c']:
4✔
501
            self['Dp_i'] = self['Dp_i'] / self['D_ref']
4✔
502
            if self['interfaceModelType'] != 'solid':
4✔
503
                self['Dm_i'] = self['Dm_i'] / self['D_ref']
×
504
            self['c0_int'] = self['c0_int'] / constants.c_ref
4✔
505
            self['cmax_i'] = self['cmax_i'] / constants.c_ref
4✔
506

507
    def _scale_electrode_parameters(self):
4✔
508
        """
509
        Scale electrode, separator, and interface region parameters to non-dimensional values.
510
        This method should be called only once, from :meth:`_process_config`.
511
        """
512
        kT = constants.k * constants.T_ref
4✔
513

514
        # scalings per electrode
515
        self['beta'] = {}
4✔
516
        for trode in self['trodes']:
4✔
517
            self['L'][trode] = self['L'][trode] / self['L_ref']
4✔
518
            self['beta'][trode] = self[trode, 'csmax'] / constants.c_ref
4✔
519
            self['sigma_s'][trode] = self['sigma_s'][trode] / self['sigma_s_ref']
4✔
520
            if self[trode, 'lambda'] is not None:
4✔
521
                self[trode, 'lambda'] = self[trode, 'lambda'] / kT
4✔
522
            if self[trode, 'B'] is not None:
4✔
523
                self[trode, 'B'] = self[trode, 'B'] / (kT * constants.N_A * self[trode, 'cs_ref'])
4✔
524
            for param in ['Omega_a', 'Omega_b', 'Omega_c', 'EvdW']:
4✔
525
                value = self[trode, param]
4✔
526
                if value is not None:
4✔
527
                    self[trode, param] = value / kT
4✔
528

529
        # scalings on separator
530
        if self['Nvol']['s']:
4✔
531
            self['L']['s'] /= self['L_ref']
4✔
532

533
        # scaling related to interface region (if present)
534
        if self['simInterface_a'] or self['simInterface_c']:
4✔
535
            self['L_i'] /= self['L_ref']
4✔
536

537
    def _scale_macroscopic_parameters(self, Vref):
4✔
538
        """
539
        Scale macroscopic input parameters to non-dimensional values and add
540
        reference values.
541
        This method should be called only once, from :meth:`_process_config`.
542
        """
543

544
        # scaling/addition of macroscopic input information
545
        factor = constants.e / (constants.k * constants.T_ref)
4✔
546
        if self['Vset'] is not None:
4✔
547
            self['Vset'] = -(factor * self['Vset'] + Vref)
4✔
548
        self['phimin'] = -(factor * self['Vmax'] + Vref)
4✔
549
        self['phimax'] = -(factor * self['Vmin'] + Vref)
4✔
550

551
    def _scale_current_voltage_segments(self, theoretical_1C_current, Vref):
4✔
552
        """
553
        Process current or voltage segments. This method should be called only once,
554
        from :meth:`_process_config`.
555
        """
556
        kT = constants.k * constants.T_ref
4✔
557

558
        # Scaling of current and voltage segments
559
        segments = []
4✔
560
        if self['profileType'] == 'CCsegments':
4✔
561
            for i in range(len(self['segments'])):
4✔
562
                segments.append((self["segments"][i][0]*self["1C_current_density"]
4✔
563
                                / theoretical_1C_current/self['curr_ref'],
564
                                self["segments"][i][1]*60/self['t_ref']))
565
        elif self['profileType'] == 'CVsegments':
4✔
566
            for i in range(len(self['segments'])):
4✔
567
                segments.append((-((constants.e/kT)*self['segments'][i][0]+Vref),
4✔
568
                                self['segments'][i][1]*60/self['t_ref']))
569
        elif self['profileType'] == 'CCCVCPcycle':
4✔
570
            # just a simple cycler
571
            for i in range(len(self["segments"])):
4✔
572

573
                # find hard capfrac cutoff (0.99 for charge, 0.01 for discharge)
574
                hard_cut = self["capFrac"] if self["segments"][i][5] <= 2 else 1 - \
4✔
575
                    self["capFrac"]
576
                # if input is None, stores as None for cutoffs only. otherwise
577
                # nondimensionalizes cutoffs & setpoints
578
                volt_cut = None if self["segments"][i][1] is None else - \
4✔
579
                    ((constants.e/kT)*(self["segments"][i][1])+Vref)
580
                # we set capfrac cutoff to be 0.99 if it is not set to prevent overfilling
581
                # capfrac_cut = 0.99 if dD_s["segments"][i][2] == None else
582
                # dD_s["segments"][i][2]
583
                capfrac_cut = hard_cut if self["segments"][i][2] is None \
4✔
584
                    else self["segments"][i][2]
585
                crate_cut = None if self["segments"][i][3] is None else \
4✔
586
                    self['segments'][i][3] * self["1C_current_density"] / \
587
                    theoretical_1C_current / self['curr_ref']
588
                time_cut = None if self["segments"][i][4] is None else \
4✔
589
                    self["segments"][i][4]*60/self['t_ref']
590
                if not (volt_cut or capfrac_cut or crate_cut or time_cut):
4✔
591
                    print(
×
592
                        "Warning: in segment "
593
                        + str(i)
594
                        + " of the cycle no cutoff is specified.")
595
                if self["segments"][i][5] == 1 or self["segments"][i][5] == 4:
4✔
596
                    # stores Crate, voltage cutoff, capfrac cutoff, C-rate cutoff(none),  time
597
                    # cutoff, type
598
                    segments.append(
4✔
599
                        (self["segments"][i][0]
600
                         * self["1C_current_density"]
601
                         / theoretical_1C_current
602
                         / self['curr_ref'],
603
                         volt_cut,
604
                         capfrac_cut,
605
                         None,
606
                         time_cut,
607
                         self["segments"][i][5]))
608
                elif self["segments"][i][5] == 2 or self["segments"][i][5] == 5:
×
609
                    # stores voltage, voltage cutoff (none), capfrac cutoff, C-rate cutoff,
610
                    # time cutoff, type
611
                    segments.append((-((constants.e/kT)*(
×
612
                        self["segments"][i][0])+Vref), None, capfrac_cut, crate_cut, time_cut,
613
                        self["segments"][i][5]))
614
                # elif CP segments
615
                elif self["segments"][i][5] == 3 or self["segments"][i][5] == 6:
×
616
                    segments.append((-(constants.e/(kT*self['curr_ref']
×
617
                                                    * theoretical_1C_current))
618
                                     * self["segments"][i][0], volt_cut, capfrac_cut,
619
                                     crate_cut, time_cut, self["segments"][i][5]))
620
                # elif just incrementing step
621
                elif self["segments"][i][5] == 0:
×
622
                    segments.append((0, None, None, None, None, 0))
×
623

624
        # Current or voltage segments profiles
625
        segments_tvec = np.zeros(2 * self['numsegments'] + 1)
4✔
626
        segments_setvec = np.zeros(2 * self['numsegments'] + 1)
4✔
627
        if self['profileType'] == 'CCsegments':
4✔
628
            segments_setvec[0] = 0.
4✔
629
        elif self['profileType'] == 'CVsegments':
4✔
630
            segments_setvec[0] = -(kT / constants.e) * Vref
4✔
631
        tPrev = 0.
4✔
632
        if self['profileType'] == 'CCsegments' or self['profileType'] == 'CVsegments':
4✔
633
            for segIndx in range(self['numsegments']):
4✔
634
                tNext = tPrev + self['tramp']
4✔
635
                segments_tvec[2*segIndx+1] = tNext
4✔
636
                tPrev = tNext
4✔
637
                # Factor of 60 here to convert to s
638
                tNext = tPrev + (self['segments'][segIndx][1] * 60 - self["tramp"])
4✔
639
                segments_tvec[2*segIndx+2] = tNext
4✔
640
                tPrev = tNext
4✔
641
                setNext = self['segments'][segIndx][0]
4✔
642
                segments_setvec[2*segIndx+1] = setNext
4✔
643
                segments_setvec[2*segIndx+2] = setNext
4✔
644
            segments_tvec /= self['t_ref']
4✔
645
        if self['profileType'] == 'CCsegments':
4✔
646
            segments_setvec *= self["1C_current_density"]/theoretical_1C_current/self['curr_ref']
4✔
647
        elif self['profileType'] == 'CVsegments':
4✔
648
            segments_setvec = -((constants.e/kT)*segments_setvec + Vref)
4✔
649
        if 'segments' in self['profileType']:
4✔
650
            self['tend'] = segments_tvec[-1]
4✔
651
            # Pad the last segment so no extrapolation occurs
652
            segments_tvec[-1] *= 1.01
4✔
653
        else:
654
            self['tend'] /= self['t_ref']
4✔
655
        self['segments'] = segments
4✔
656
        self['segments_tvec'] = segments_tvec
4✔
657
        self['segments_setvec'] = segments_setvec
4✔
658
        if self['profileType'] == 'CC' and not np.allclose(self['currset'], 0., atol=1e-12):
4✔
659
            self['tend'] = np.abs(self['capFrac'] / self['currset'])
4✔
660

661
    def _make_paths_absolute(self):
4✔
662
        """
663
        Make paths in config absolute. This only applies to paths used in MPET simulations,
664
        not to the cathode/anode parameter files and prevDir.
665
        """
666
        # filenames in global config
667
        for key in ["SMset_filename"]:
4✔
668
            path = self[key]
4✔
669
            if path is not None and not os.path.isabs(path):
4✔
670
                self[key] = os.path.abspath(os.path.join(self.path, path))
×
671
        # filenames in trode config
672
        for key in ["rxnType_filename", "muRfunc_filename", "Dfunc_filename"]:
4✔
673
            for trode in self['trodes']:
4✔
674
                path = self[trode, key]
4✔
675
                if path is not None and not os.path.isabs(path):
4✔
676
                    self[trode, key] = os.path.abspath(os.path.join(self.path, path))
×
677

678
    def _distr_part(self):
4✔
679
        """
680
        Generate particle distributions and store in config.
681
        """
682
        # intialize dicts in config
683
        self['psd_num'] = {}
4✔
684
        self['psd_len'] = {}
4✔
685
        self['psd_area'] = {}
4✔
686
        self['psd_vol'] = {}
4✔
687
        self['psd_vol_FracVol'] = {}
4✔
688

689
        self['gamma_contact'] = {}
4✔
690

691
        for trode in self['trodes']:
4✔
692
            solidType = self[trode, 'type']
4✔
693
            Nvol = self['Nvol'][trode]
4✔
694
            Npart = self['Npart'][trode]
4✔
695

696
            # check if PSD is specified. If so, it is an ndarray so use np.all
697
            if not np.all(self['specified_psd'][trode]):
4✔
698
                # If PSD is not specified, make a length-sampled particle size distribution
699
                # Log-normally distributed
700
                mean = self['mean'][trode]
4✔
701
                stddev = self['stddev'][trode]
4✔
702
                if np.allclose(stddev, 0., atol=1e-12):
4✔
703
                    raw = mean * np.ones((Nvol, Npart))
4✔
704
                else:
705
                    var = stddev**2
4✔
706
                    mu = np.log((mean**2) / np.sqrt(var + mean**2))
4✔
707
                    sigma = np.sqrt(np.log(var/(mean**2) + 1))
4✔
708
                    raw = np.random.lognormal(mu, sigma, size=(Nvol, Npart))
4✔
709
            else:
710
                # use user-defined PSD
711
                raw = self['specified_psd'][trode]
4✔
712
                if raw.shape != (Nvol, Npart):
4✔
713
                    raise ValueError('Specified particle size distribution discretization '
×
714
                                     'of volumes inequal to the one specified in the config file')
715

716
            stddev_c = self['stand_dev_contact']
4✔
717
            mean_c = self['fraction_of_contact']
4✔
718

719
            if 0 < mean_c < 1:
4✔
720
                # Contact penalty for BV
721
                mean_c = 1 - mean_c  # to make distribution start at 1 if gamma is 1
4✔
722
                var_c = stddev_c**2
4✔
723
                mu_c = np.log((mean_c**2) / np.sqrt(var_c + mean_c**2))
4✔
724
                sigma_c = np.sqrt(np.log(var_c/(mean_c**2) + 1))
4✔
725
                raw_c = 1 - np.random.lognormal(mu_c, sigma_c, size=(Nvol, Npart))
4✔
726
                raw_c[raw_c <= 0.0001] = 0.0001
4✔
727
                gamma_contact = raw_c
4✔
728
            elif mean_c == 1:
4✔
729
                gamma_contact = np.ones((Nvol, Npart))
4✔
730
            else:
731
                raise NotImplementedError('Contact error should be between 0 and 1')
×
732

733
            # For particles with internal profiles, convert psd to
734
            # integers -- number of steps
735
            solidDisc = self[trode, 'discretization']
4✔
736
            if solidType in ['ACR','ACR_Diff','ACR2']:
4✔
737
                psd_num = np.ceil(raw / solidDisc).astype(int)
4✔
738
                psd_len = solidDisc * psd_num
4✔
739
            elif solidType in ['CHR', 'diffn', 'CHR2', 'diffn2']:
4✔
740
                psd_num = np.ceil(raw / solidDisc).astype(int) + 1
4✔
741
                psd_len = solidDisc * (psd_num - 1)
4✔
742
            # For homogeneous particles (only one 'volume' per particle)
743
            elif solidType in ['homog', 'homog_sdn', 'homog2', 'homog2_sdn']:
4✔
744
                # Each particle is only one volume
745
                psd_num = np.ones(raw.shape, dtype=int)
4✔
746
                # The lengths are given by the original length distr.
747
                psd_len = raw
4✔
748
            else:
749
                raise NotImplementedError(f'Unknown solid type: {solidType}')
×
750

751
            # Calculate areas and volumes
752
            solidShape = self[trode, 'shape']
4✔
753
            if solidShape == 'sphere':
4✔
754
                psd_area = 4 * np.pi * psd_len**2
4✔
755
                psd_vol = (4. / 3) * np.pi * psd_len**3
4✔
756
            elif solidShape == 'C3':
4✔
757
                psd_area = 2 * 1.2263 * psd_len**2
4✔
758
                psd_vol = 1.2263 * psd_len**2 * self[trode, 'thickness']
4✔
759
            elif solidShape == 'cylinder':
4✔
760
                psd_area = 2 * np.pi * psd_len * self[trode, 'thickness']
4✔
761
                psd_vol = np.pi * psd_len**2 * self[trode, 'thickness']
4✔
762
            else:
763
                raise NotImplementedError(f'Unknown solid shape: {solidShape}')
×
764

765
            # Fraction of individual particle volume compared to total
766
            # volume of particles _within the simulated electrode
767
            # volume_
768
            psd_frac_vol = psd_vol / psd_vol.sum(axis=1, keepdims=True)
4✔
769

770
            # store values to config
771
            self['psd_num'][trode] = psd_num
4✔
772
            self['psd_len'][trode] = psd_len
4✔
773
            self['psd_area'][trode] = psd_area
4✔
774
            self['psd_vol'][trode] = psd_vol
4✔
775
            self['psd_vol_FracVol'][trode] = psd_frac_vol
4✔
776
            self['gamma_contact'][trode] = gamma_contact
4✔
777

778
    def _G(self):
4✔
779
        """
780
        Generate Gibbs free energy distribution and store in config.
781
        """
782
        self['G'] = {}
4✔
783
        for trode in self['trodes']:
4✔
784
            Nvol = self['Nvol'][trode]
4✔
785
            Npart = self['Npart'][trode]
4✔
786
            mean = self['G_mean'][trode]
4✔
787
            stddev = self['G_stddev'][trode]
4✔
788
            if np.allclose(stddev, 0, atol=1e-12):
4✔
789
                G = mean * np.ones((Nvol, Npart))
4✔
790
            else:
791
                var = stddev**2
×
792
                mu = np.log((mean**2) / np.sqrt(var + mean**2))
×
793
                sigma = np.sqrt(np.log(var / (mean**2) + 1))
×
794
                G = np.random.lognormal(mu, sigma, size=(Nvol, Npart))
×
795

796
            # scale and store
797
            self['G'][trode] = G * constants.k * constants.T_ref * self['t_ref'] \
4✔
798
                / (constants.e * constants.F * self[trode, 'csmax'] * self['psd_vol'][trode])
799

800
    def _indvPart(self):
4✔
801
        """
802
        Generate particle-specific parameter values and store in config.
803
        """
804
        for trode in self['trodes']:
4✔
805
            Nvol = self['Nvol'][trode]
4✔
806
            Npart = self['Npart'][trode]
4✔
807
            self[trode, 'indvPart'] = {}
4✔
808

809
            # intialize parameters
810
            for param, dtype in constants.PARAMS_PARTICLE.items():
4✔
811
                self[trode, 'indvPart'][param] = np.empty((Nvol, Npart), dtype=dtype)
4✔
812

813
            # reference scales per trode
814
            cs_ref_part = constants.N_A * self[trode, 'cs_ref']  # part/m^3
4✔
815

816
            # calculate the values for each particle in each volume
817
            for i in range(Nvol):
4✔
818
                for j in range(Npart):
4✔
819
                    # This specific particle dimensions
820
                    self[trode, 'indvPart']['N'][i, j] = self['psd_num'][trode][i,j]
4✔
821
                    plen = self['psd_len'][trode][i,j]
4✔
822
                    parea = self['psd_area'][trode][i,j]
4✔
823
                    pvol = self['psd_vol'][trode][i,j]
4✔
824
                    gamma_cont = self['gamma_contact'][trode][i,j]
4✔
825
                    # Define a few reference scales
826
                    F_s_ref = plen * cs_ref_part / self['t_ref']  # part/(m^2 s)
4✔
827
                    i_s_ref = constants.e * F_s_ref  # A/m^2
4✔
828
                    kappa_ref = constants.k * constants.T_ref * cs_ref_part * plen**2  # J/m
4✔
829
                    gamma_S_ref = kappa_ref / plen  # J/m^2
4✔
830
                    # non-dimensional quantities
831
                    if self[trode, 'kappa'] is not None:
4✔
832
                        self[trode, 'indvPart']['kappa'][i, j] = self[trode, 'kappa'] / kappa_ref
4✔
833
                    if self[trode, 'dgammadc'] is not None:
4✔
834
                        nd_dgammadc = self[trode, 'dgammadc'] * cs_ref_part / gamma_S_ref
4✔
835
                        self[trode, 'indvPart']['beta_s'][i, j] = nd_dgammadc \
4✔
836
                            / self[trode, 'indvPart']['kappa'][i, j]
837
                    self[trode, 'indvPart']['D'][i, j] = self[trode, 'D'] * self['t_ref'] / plen**2
4✔
838
                    self[trode, 'indvPart']['E_D'][i, j] = self[trode, 'E_D'] \
4✔
839
                        / (constants.k * constants.N_A * constants.T_ref)
840
                    self[trode, 'indvPart']['k0'][i, j] = self[trode, 'k0'] \
4✔
841
                        / (constants.e * F_s_ref)
842
                    self[trode, 'indvPart']['gamma_con'][i, j] = gamma_cont
4✔
843
                    if self['fraction_of_contact'] != 1.0 and not self['localized_losses']:
4✔
844
                        self[trode, 'indvPart']['k0'][i, j] = self[trode, 'k0'] \
4✔
845
                            / (constants.e * F_s_ref)*gamma_cont
846
                    self[trode, 'indvPart']['E_A'][i, j] = self[trode, 'E_A'] \
4✔
847
                        / (constants.k * constants.N_A * constants.T_ref)
848
                    self[trode, 'indvPart']['Rfilm'][i, j] = self[trode, 'Rfilm'] \
4✔
849
                        / (constants.k * constants.T_ref / (constants.e * i_s_ref))
850
                    self[trode, 'indvPart']['delta_L'][i, j] = (parea * plen) / pvol
4✔
851
                    # If we're using the model that varies Omg_a with particle size,
852
                    # overwrite its value for each particle
853
                    if self[trode, 'type'] in ['homog_sdn', 'homog2_sdn']:
4✔
854
                        self[trode, 'indvPart']['Omega_a'][i, j] = self.size2regsln(plen)
4✔
855
                    else:
856
                        # just use global value
857
                        self[trode, 'indvPart']['Omega_a'][i, j] = self[trode, 'Omega_a']
4✔
858

859
        # store which items are defined per particle, so in the future they are retrieved
860
        # per particle instead of from the values per electrode
861
        self.params_per_particle = list(constants.PARAMS_PARTICLE.keys())
4✔
862

863
    def _verify_config(self):
4✔
864
        """
865
        Verify configuration parameters.
866
        """
867
        # solid type
868
        for trode in self['trodes']:
4✔
869
            solidShape = self[trode, 'shape']
4✔
870
            solidType = self[trode, 'type']
4✔
871
            if solidType in ["ACR", "ACR_Diff", "homog_sdn", "ACR2"] and solidShape != "C3":
4✔
872
                raise Exception("ACR, ACR_Diff, ACR2 and homog_sdn req. C3 shape")
×
873
            if (solidType in ["CHR", "diffn"] and solidShape not in ["sphere", "cylinder"]):
4✔
874
                raise NotImplementedError("CHR and diffn req. sphere or cylinder")
×
875

876
    @staticmethod
4✔
877
    def size2regsln(size):
4✔
878
        """
879
        This function returns the non-dimensional regular solution
880
        parameter which creates a barrier height that corresponds to
881
        the given particle size (C3 particle, measured in nm in the
882
        [100] direction). The barrier height vs size is taken from
883
        Cogswell and Bazant 2013, and the reg sln vs barrier height
884
        was done by TRF 2014 (Ferguson and Bazant 2014).
885

886
        :param float/array size: Size of the particle(s) (m)
887

888
        :return: regular solution parameter (float/array)
889
        """
890
        # First, this function wants the argument to be in [nm]
891
        size *= 1e+9
4✔
892
        # Parameters for polynomial curve fit
893
        p1 = -1.168e4
4✔
894
        p2 = 2985
4✔
895
        p3 = -208.3
4✔
896
        p4 = -8.491
4✔
897
        p5 = -10.25
4✔
898
        p6 = 4.516
4✔
899
        # The nucleation barrier depends on the ratio of the particle
900
        # wetted area to total particle volume.
901
        # *Wetted* area to volume ratio for C3 particles (Cogswell
902
        # 2013 or Kyle Smith)
903
        AV = 3.6338/size
4✔
904
        # Fit function (TRF, "SWCS" paper 2014)
905
        param = p1*AV**5 + p2*AV**4 + p3*AV**3 + p4*AV**2 + p5*AV + p6
4✔
906
        # replace values less than 2 with 2.
907
        if isinstance(param, np.ndarray):
4✔
908
            param[param < 2] = 2.
×
909
        elif param < 2:
4✔
910
            param = 2.
4✔
911
        return param
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc