def test_update_from_dataclass(self): class S7: r""" This is a docstring """ a = 1 b = 2 class Data: a = 22 c = 5.5 sets7 = SettingsAttr(S7) assert sets7.a == 1 sets7._update(Data()) assert sets7.a == 22
class GenericTransport(GenericAlgorithm): r""" This class implements steady-state linear transport calculations. Parameters ---------- %(GenericAlgorithm.parameters)s """ def __new__(cls, *args, **kwargs): instance = super(GenericTransport, cls).__new__(cls, *args, **kwargs) # Create some instance attributes instance._A = None instance._b = None instance._pure_A = None instance._pure_b = None return instance def __init__(self, phase, settings=None, **kwargs): self.settings = SettingsAttr(GenericTransportSettings, settings) super().__init__(settings=self.settings, **kwargs) self.settings['phase'] = phase.name self['pore.bc_rate'] = np.nan self['pore.bc_value'] = np.nan @property def x(self): """Shortcut to the solution currently stored on the algorithm.""" return self[self.settings['quantity']] @x.setter def x(self, value): self[self.settings['quantity']] = value @docstr.get_full_description(base='GenericTransport.reset') @docstr.get_sections(base='GenericTransport.reset', sections=['Parameters']) @docstr.dedent def reset(self, bcs=False, results=True): r""" Resets the algorithm to enable re-use. This allows the reuse of an algorithm inside a for-loop for parametric studies. The default behavior means that only ``alg.reset()`` and ``alg.run()`` must be called inside a loop. To reset the algorithm more completely requires overriding the default arguments. Parameters ---------- results : bool If ``True`` all previously calculated values pertaining to results of the algorithm are removed. The default value is ``True``. bcs : bool If ``True`` all previous boundary conditions are removed. """ self._pure_b = self._b = None self._pure_A = self._A = None if bcs: self['pore.bc_value'] = np.nan self['pore.bc_rate'] = np.nan if results: self.pop(self.settings['quantity'], None) @docstr.dedent def set_value_BC(self, pores, values, mode='merge'): r""" Applues constant value boundary conditons to the specified pores. These are sometimes referred to as Dirichlet conditions. Parameters ---------- pores : array_like The pore indices where the condition should be applied values : float or array_like The value to apply in each pore. If a scalar is supplied it is assigne to all locations, and if a vector is applied is must be the same size as the indices given in ``pores``. mode : str, optional Controls how the boundary conditions are applied. The default value is 'merge'. Options are: =========== ===================================================== mode meaning =========== ===================================================== 'merge' Adds supplied boundary conditions to already existing conditions, and also overwrites any existing values. If BCs of the complementary type already exist in the given locations, those values are kept. 'overwrite' Deletes all boundary conditions of the given type then adds the specified new ones (unless locations already have BCs of the other type) =========== ===================================================== Notes ----- The definition of ``quantity`` is specified in the algorithm's ``settings``, e.g. ``alg.settings['quantity'] = 'pore.pressure'``. """ self._set_BC(pores=pores, bctype='value', bcvalues=values, mode=mode) def set_rate_BC(self, pores, rates=None, total_rate=None, mode='merge', **kwargs): r""" Apply constant rate boundary conditons to the specified locations. Parameters ---------- pores : array_like The pore indices where the condition should be applied rates : float or array_like, optional The rates to apply in each pore. If a scalar is supplied that rate is assigned to all locations, and if a vector is supplied it must be the same size as the indices given in ``pores``. total_rate : float, optional The total rate supplied to all pores. The rate supplied by this argument is divided evenly among all pores. A scalar must be supplied! Total_rate cannot be specified if rate is specified. mode : str, optional Controls how the boundary conditions are applied. The default value is 'merge'. Options are: =========== ===================================================== mode meaning =========== ===================================================== 'merge' Adds supplied boundary conditions to already existing conditions, and also overwrites any existing values. If BCs of the complementary type already exist in the given locations, those values are kept. 'overwrite' Deletes all boundary conditions of the given type then adds the specified new ones (unless locations already have BCs of the other type) =========== ===================================================== Notes ----- The definition of ``quantity`` is specified in the algorithm's ``settings``, e.g. ``alg.settings['quantity'] = 'pore.pressure'``. """ # support 'values' keyword if 'values' in kwargs.keys(): rates = kwargs.pop("values") warnings.warn("'values' has been deprecated, use 'rates' instead.", DeprecationWarning) # handle total_rate feature if total_rate is not None: if not np.isscalar(total_rate): raise Exception('total_rate argument accepts scalar only!') if rates is not None: raise Exception('Cannot specify both arguments: rate and ' + 'total_rate') pores = self._parse_indices(pores) rates = total_rate / pores.size self._set_BC(pores=pores, bctype='rate', bcvalues=rates, mode=mode) @docstr.get_sections(base='GenericTransport._set_BC', sections=['Parameters', 'Notes']) def _set_BC(self, pores, bctype, bcvalues=None, mode='merge'): r""" This private method is called by public facing BC methods, to apply boundary conditions to specified pores Parameters ---------- pores : array_like The pores where the boundary conditions should be applied bctype : str Specifies the type or the name of boundary condition to apply. The types can be one one of the following: =========== ===================================================== bctype meaning =========== ===================================================== 'value' Specify the value of the quantity in each pore 'rate' Specify the flow rate into each pore =========== ===================================================== bcvalues : int or array_like The boundary value to apply, such as concentration or rate. If a single value is given, it's assumed to apply to all locations unless the 'total_rate' bc_type is supplied whereby a single value corresponds to a total rate to be divded evenly among all pores. Otherwise, different values can be applied to all pores in the form of an array of the same length as ``pores``. mode : str, optional Controls how the boundary conditions are applied. The default value is 'merge'. Options are: =========== ===================================================== mode meaning =========== ===================================================== 'merge' Adds supplied boundary conditions to already existing conditions, and also overwrites any existing values. If BCs of the complementary type already exist in the given locations, these values are kept. 'overwrite' Deletes all boundary conditions of the given type then adds the specified new ones (unless locations already have BCs of the other type). =========== ===================================================== Notes ----- It is not possible to have multiple boundary conditions for a specified location in one algorithm. Use ``remove_BCs`` to clear existing BCs before applying new ones or ``mode='overwrite'`` which removes all existing BC's before applying the new ones. """ # Hijack the parse_mode function to verify bctype argument bctype = self._parse_mode(bctype, allowed=['value', 'rate'], single=True) othertype = np.setdiff1d(['value', 'rate'], bctype).item() mode = self._parse_mode(mode, allowed=['merge', 'overwrite'], single=True) pores = self._parse_indices(pores) values = np.array(bcvalues) if values.size > 1 and values.size != pores.size: raise Exception( 'The number of values must match the number of locations') # Catch pores with existing BCs if mode == 'merge': # Remove offenders, and warn user existing_bcs = np.isfinite(self[f"pore.bc_{othertype}"]) inds = pores[existing_bcs[pores]] elif mode == 'overwrite': # Remove existing BCs and write new ones self[f"pore.bc_{bctype}"] = np.nan existing_bcs = np.isfinite(self[f"pore.bc_{othertype}"]) inds = pores[existing_bcs[pores]] # Now drop any pore indices which have BCs that should be kept if len(inds) > 0: msg = ( r'Boundary conditions are already specified in the following given' f' pores, so these will be skipped: {inds.__repr__()}') logger.warning(prettify_logger_message(msg)) pores = np.setdiff1d(pores, inds) # Store boundary values self[f"pore.bc_{bctype}"][pores] = values def remove_BC(self, pores=None, bctype='all'): r""" Removes boundary conditions from the specified pores. Parameters ---------- pores : array_like, optional The pores from which boundary conditions are to be removed. If no pores are specified, then BCs are removed from all pores. No error is thrown if the provided pores do not have any BCs assigned. bctype : str, or List[str] Specifies which type of boundary condition to remove. The default value is 'all'. Options are: =========== ===================================================== bctype meaning =========== ===================================================== 'all' Removes all boundary conditions 'value' Removes only value conditions 'rate' Removes only rate conditions =========== ===================================================== """ if isinstance(bctype, str): bctype = [bctype] if 'all' in bctype: bctype = ['value', 'rate'] if pores is None: pores = self.Ps if ('pore.bc_value' in self.keys()) and ('value' in bctype): self['pore.bc_value'][pores] = np.nan if ('pore.bc_rate' in self.keys()) and ('rate' in bctype): self['pore.bc_rate'][pores] = np.nan def _build_A(self): r""" Builds the coefficient matrix based on throat conductance values. Notes ----- The conductance to use is specified in stored in the algorithm's settings under ``alg.settings['conductance']``. In subclasses, conductance is set by default. For instance, in ``FickianDiffusion``, it is set to ``throat.diffusive_conductance``, although it can be changed. """ gvals = self.settings['conductance'] # FIXME: this needs to be properly addressed (see issue #1548) try: if gvals in self._get_iterative_props(): self.settings._update({"cache_A": False, "cache_b": False}) except AttributeError: pass if not self.settings['cache_A']: self._pure_A = None if self._pure_A is None: phase = self.project[self.settings.phase] g = phase[gvals] am = self.network.create_adjacency_matrix(weights=g, fmt='coo') self._pure_A = spgr.laplacian(am).astype(float) self.A = self._pure_A.copy() def _build_b(self): r""" Builds the RHS vector, without applying any boundary conditions or source terms. This method is trivial an basically creates a column vector of 0's. """ if not self.settings['cache_b']: self._pure_b = None if self._pure_b is None: b = np.zeros(self.Np, dtype=float) self._pure_b = b self.b = self._pure_b.copy() @property def A(self): """The coefficients matrix, A (in Ax = b)""" if self._A is None: self._build_A() return self._A @A.setter def A(self, value): self._A = value @property def b(self): """The right-hand-side (RHS) vector, b (in Ax = b)""" if self._b is None: self._build_b() return self._b @b.setter def b(self, value): self._b = value def _apply_BCs(self): r""" Applies all the boundary conditions that have been specified, by adding values to the *A* and *b* matrices. """ if 'pore.bc_rate' in self.keys(): # Update b ind = np.isfinite(self['pore.bc_rate']) self.b[ind] = self['pore.bc_rate'][ind] if 'pore.bc_value' in self.keys(): f = self.A.diagonal().mean() # Update b (impose bc values) ind = np.isfinite(self['pore.bc_value']) self.b[ind] = self['pore.bc_value'][ind] * f # Update b (substract quantities from b to keep A symmetric) x_BC = np.zeros_like(self.b) x_BC[ind] = self['pore.bc_value'][ind] self.b[~ind] -= (self.A * x_BC)[~ind] # Update A P_bc = self.to_indices(ind) mask = np.isin(self.A.row, P_bc) | np.isin(self.A.col, P_bc) # Remove entries from A for all BC rows/cols self.A.data[mask] = 0 # Add diagonal entries back into A datadiag = self.A.diagonal() datadiag[P_bc] = np.ones_like(P_bc, dtype=float) * f self.A.setdiag(datadiag) self.A.eliminate_zeros() def run(self, solver=None, x0=None): r""" Builds the A and b matrices, and calls the solver specified in the ``settings`` attribute. Parameters ---------- x0 : ndarray Initial guess of unknown variable Notes ----- This method doesn't return anything. The solution is stored on the object under ``pore.quantity`` where *quantity* is specified in the ``settings`` attribute. """ logger.info('Running GenericTransport') solver = PardisoSpsolve() if solver is None else solver # Perform pre-solve validations self._validate_settings() self._validate_data_health() # Write x0 to algorithm the obj (needed by _update_iterative_props) x0 = np.zeros_like(self.b) if x0 is None else x0 self[self.settings["quantity"]] = x0 self["pore.initial_guess"] = x0 # Build A and b, then solve the system of equations self._update_A_and_b() self._run_special(solver=solver, x0=x0) def _run_special(self, solver, x0, w=1): # Make sure A,b are STILL well-defined self._validate_data_health() # Solve and apply under-relaxation x_new, exit_code = solver.solve(A=self.A, b=self.b, x0=x0) quantity = self.settings['quantity'] self[quantity] = w * x_new + (1 - w) * self[quantity] # Update A and b using the recent solution self._update_A_and_b() def _update_A_and_b(self): r""" Builds/updates A, b based on the recent solution on the algorithm object. """ self._build_A() self._build_b() self._apply_BCs() def _validate_settings(self): if self.settings['quantity'] is None: raise Exception("'quantity' hasn't been defined on this algorithm") if self.settings['conductance'] is None: raise Exception( "'conductance' hasn't been defined on this algorithm") if self.settings['phase'] is None: raise Exception("'phase' hasn't been defined on this algorithm") def _validate_geometry_health(self): h = self.project.check_geometry_health() issues = [k for k, v in h.items() if v] if issues: msg = (r"Found the following critical issues with your geomet" f"ry objects: {', '.join(issues)}. Run project.check_g" "eometry_health() for more details.") raise Exception(msg) def _validate_topology_health(self): Ps = ~np.isnan(self['pore.bc_rate']) + ~np.isnan(self['pore.bc_value']) if not is_fully_connected(network=self.network, pores_BC=Ps): msg = ("Your network is clustered. Run h = net.check_network_" "health() followed by op.topotools.trim(net, pores=h['" "trim_pores']) to make your network fully connected.") raise Exception(msg) def _validate_data_health(self): r""" Check whether A and b are well-defined, i.e. doesn't contain nans. """ import networkx as nx from pandas import unique # Validate network topology health self._validate_topology_health() # Short-circuit subsequent checks if data are healthy if np.isfinite(self.A.data).all() and np.isfinite(self.b).all(): return True # Validate geometry health self._validate_geometry_health() # Fetch phase/geometries/physics prj = self.network.project phase = prj.find_phase(self) geometries = prj.geometries().values() physics = prj.physics().values() # Locate the root of NaNs unaccounted_nans = [] for geom, phys in zip(geometries, physics): objs = [phase, geom, phys] # Generate global dependency graph dg = nx.compose_all( [x.models.dependency_graph(deep=True) for x in objs]) d = {} # maps prop -> obj.name for obj in objs: for k, v in obj.check_data_health().items(): if "Has NaNs" in v: # FIXME: The next line doesn't cover multi-level props base_prop = ".".join(k.split(".")[:2]) if base_prop in dg.nodes: d[base_prop] = obj.name else: unaccounted_nans.append(base_prop) # Generate dependency subgraph for props with NaNs dg_nans = nx.subgraph(dg, d.keys()) # Find prop(s)/object(s) from which NaNs have propagated root_props = [n for n in d.keys() if not nx.ancestors(dg_nans, n)] root_objs = unique([d[x] for x in nx.topological_sort(dg_nans)]) # Throw error with helpful info on how to resolve the issue if root_props: msg = ( "Found NaNs in A matrix, possibly caused by NaNs in" f" {', '.join(root_props)}. The issue might get resolved" " if you call `regenerate_models` on the following" f" object(s): {', '.join(root_objs)}") raise Exception(msg) # Raise Exception for unaccounted properties if unaccounted_nans: msg = ("Found NaNs in A matrix, possibly caused by NaNs in" f" {', '.join(unaccounted_nans)}.") raise Exception(msg) # Raise Exception otherwise if root cannot be found msg = ("Found NaNs in A matrix but couldn't locate the root cause." " It's likely that disabling caching of A matrix via" " `alg.settings['cache_A'] = False` after instantiating the" " algorithm object fixes the problem.") raise Exception(msg) def results(self): r""" Fetches the calculated quantity from the algorithm and returns it as an array. """ quantity = self.settings['quantity'] return {quantity: self[quantity]} def rate(self, pores=[], throats=[], mode='group'): r""" Calculates the net rate of material moving into a given set of pores or throats Parameters ---------- pores : array_like The pores for which the rate should be calculated throats : array_like The throats through which the rate should be calculated mode : str, optional Controls how to return the rate. The default value is 'group'. Options are: =========== ===================================================== mode meaning =========== ===================================================== 'group' Returns the cumulative rate of material 'single' Calculates the rate for each pore individually =========== ===================================================== Returns ------- If ``pores`` are specified, then the returned values indicate the net rate of material exiting the pore or pores. Thus a positive rate indicates material is leaving the pores, and negative values mean material is entering. If ``throats`` are specified the rate is calculated in the direction of the gradient, thus is always positive. If ``mode`` is 'single' then the cumulative rate through the given pores (or throats) are returned as a vector, if ``mode`` is 'group' then the individual rates are summed and returned as a scalar. """ pores = self._parse_indices(pores) throats = self._parse_indices(throats) if throats.size > 0 and pores.size > 0: raise Exception('Must specify either pores or throats, not both') if throats.size == pores.size == 0: raise Exception('Must specify either pores or throats') network = self.project.network phase = self.project.phases()[self.settings['phase']] g = phase[self.settings['conductance']] quantity = self[self.settings['quantity']] P12 = network['throat.conns'] X12 = quantity[P12] if g.size == self.Nt: g = np.tile(g, (2, 1)).T # Make conductance a Nt by 2 matrix # The next line is critical for rates to be correct g = np.flip(g, axis=1) Qt = np.diff(g * X12, axis=1).squeeze() if throats.size: R = np.absolute(Qt[throats]) if mode == 'group': R = np.sum(R) if pores.size: Qp = np.zeros((self.Np, )) np.add.at(Qp, P12[:, 0], -Qt) np.add.at(Qp, P12[:, 1], Qt) R = Qp[pores] if mode == 'group': R = np.sum(R) return np.array(R, ndmin=1) def set_variable_props(self, variable_props, mode='merge'): r""" This method is useful for setting variable_props to the settings dictionary of the target object. Variable_props and their dependent properties get updated iteratively. Parameters ---------- variable_props : str, or List(str) A single string or list of strings to be added as variable_props mode : str, optional Controls how the variable_props are applied. The default value is 'merge'. Options are: =========== ===================================================== mode meaning =========== ===================================================== 'merge' Adds supplied variable_props to already existing list (if any), and prevents duplicates 'overwrite' Deletes all exisitng variable_props and then adds the specified new ones =========== ===================================================== """ # If single string, make it a list if isinstance(variable_props, str): variable_props = [variable_props] # Handle mode mode = self._parse_mode(mode, allowed=['merge', 'overwrite'], single=True) if mode == 'overwrite': self.settings['variable_props'] = [] # parse each propname and append to variable_props in settings for variable_prop in variable_props: variable_prop = self._parse_prop(variable_prop, 'pore') self.settings['variable_props'].append(variable_prop)