class SingleMatrixLCA(object): """An LCA which puts everything into one matrix. Comes with advantages and disadvantages, and designed exclusively for `BONSAI <https://bonsai.uno/>`__ via `beebee <https://github.com/BONSAMURAIS/beebee/>`__.""" def __init__(self, demand, data_filepath, log_config=None, presamples=None, seed=None, override_presamples_seed=False): """Create a new single-matrix LCA calculation. Args: * *demand* (dict): The demand or functional unit. Needs to be a dictionary to indicate amounts, e.g. ``{("my database", "my process"): 2.5}``. Returns: A new ``SingleMatrixLCA`` object """ if not isinstance(demand, Mapping): raise ValueError("Demand must be a dictionary") if log_config: create_logger(**log_config) self.logger = logging.getLogger('bw2calc') self.demand = demand self.filepath = data_filepath self.seed = seed if presamples and PackagesDataLoader is None: warnings.warn("Skipping presamples; `presamples` not installed") self.presamples = None elif presamples: # Iterating over a `Campaign` object will also return the presample filepaths self.presamples = PackagesDataLoader( dirpaths=presamples, seed=self.seed if override_presamples_seed else None, lca=self) else: self.presamples = None self.logger.info("Created LCA object", extra={ 'demand': str(self.demand), 'data_filepath': self.filepath, 'presamples': str(self.presamples), }) def build_demand_array(self, demand=None): """Turn the demand dictionary into a *NumPy* array of correct size. Args: * *demand* (dict, optional): Demand dictionary. Optional, defaults to ``self.demand``. Returns: A 1-dimensional NumPy array """ demand = demand or self.demand self.demand_array = np.zeros(len(self.row_dict)) for key in demand: try: self.demand_array[self.row_dict[key]] = demand[key] except KeyError: if key in self.col_dict: raise ValueError( (u"LCA can only be performed on products," u" not activities ({} is the wrong dimension)" ).format(key)) else: raise OutsideTechnosphere( "Can't find key {} in product dictionary".format(key)) ######################### ### Data manipulation ### ######################### def fix_dictionaries(self, row_mapping, col_mapping): """Fix the row and column dictionaries from ``{integer: row/col index}`` to ``{label: row/col index}``.""" self.row_dict = { label: self.row_dict[index] for label, index in row_mapping.items() } self.col_dict = { label: self.col_dict[index] for label, index in col_mapping.items() } def reverse_dict(self): """Construct reverse dicts from technosphere and biosphere row and col indices to activity_dict/product_dict/biosphere_dict keys. Returns: (reversed ``self.activity_dict``, ``self.product_dict`` and ``self.biosphere_dict``) """ rev_row = {v: k for k, v in self.row_dict.items()} rev_col = {v: k for k, v in self.col_dict.items()} return rev_row, rev_col ###################### ### Data retrieval ### ###################### def load_beebee_data(self, builder=SingleMatrixBuilder): """Load ``beebee`` export data package. This is a compressed file which contains: * A `stats_arrays <https://bitbucket.org/cmutel/stats_arrays>`__ file used to create the single matrix. * A mapping dictionary from meaningful labels to the integer row ids * A mapping dictionary from meaningful labels to the integer column ids * A mapping dictionary from ``{"method URI": {labels}}`` which allows for LCIA sums """ with tarfile.open(self.filepath, 'r:bz2') as f: # Hack needed because of https://github.com/numpy/numpy/issues/7989 array_file = BytesIO() array_file.write(f.extractfile("array.npy").read()) array_file.seek(0) self.params, self.row_dict, self.col_dict, \ self.matrix = builder.build(array_file) row_mapping = json.load(f.extractfile("row.mapping")) col_mapping = json.load(f.extractfile("col.mapping")) categories = json.load(f.extractfile("categories.mapping")) if len(self.row_dict) != len(self.col_dict): raise NonsquareTechnosphere(( "Single matrix is not square: {} columns and {} rows.").format( len(self.row_dict), len(self.col_dict))) self.fix_dictionaries(row_mapping, col_mapping) self.category_index_arrays = { name: np.array([(self.row_dict[label], 1) for label in labels], dtype=[('INDEX', np.uint32), ('CONSTANT', np.uint32)]) for name, labels in categories.items() } # Only need to index here for traditional LCA if self.presamples: self.presamples.index_arrays(self) self.presamples.update_matrices(matrices=('matrix', )) def decompose_technosphere(self): """ Factorize the technosphere matrix into lower and upper triangular matrices, :math:`A=LU`. Does not solve the linear system :math:`Ax=B`. Doesn't return anything, but creates ``self.solver``. .. warning:: Incorrect results could occur if a technosphere matrix was factorized, and then a new technosphere matrix was constructed, as ``self.solver`` would still be the factorized older technosphere matrix. You are responsible for deleting ``self.solver`` when doing these types of advanced calculations. """ self.solver = factorized(self.matrix.tocsc()) def solve_linear_system(self): """ Master solution function for linear system :math:`Ax=B`. To most numerical analysts, matrix inversion is a sin. -- Nicolas Higham, Accuracy and Stability of Numerical Algorithms, Society for Industrial and Applied Mathematics, Philadelphia, PA, USA, 2002, p. 260. We use `UMFpack <http://www.cise.ufl.edu/research/sparse/umfpack/>`_, which is a very fast solver for sparse matrices. If the technosphere matrix has already been factorized, then the decomposed technosphere (``self.solver``) is reused. Otherwise the calculation is redone completely. """ if hasattr(self, "solver"): return self.solver(self.demand_array) else: return spsolve(self.matrix, self.demand_array) def lcia(self, *args, **kwargs): warnings.warn("LCIA does nothing in a SingleMatrixLCA") def calculate(self, factorize=False, builder=SingleMatrixBuilder): """Calculate an LCA score. Creates ``self.supply_array``, a vector of activities, flows, and characterization pathways which satisfy the demand. Creates ``self.scores``, a dictionary of ``{'LCIA identifier': LCA score}``. Create ``self.contributions``, a dictionary of ``{'LCIA identifier': []}``. Args: * *factorize* (bool, optional): Factorize the technosphere matrix. Makes additional calculations with the same technosphere matrix much faster. Default is ``False``; not useful is only doing one LCI calculation. * *builder* (``SingleMatrixBuilder`` object, optional): Custom matrix builders can be used to manipulate data in creative ways before building the matrices. Doesn't return anything. """ self.load_beebee_data(builder) self.build_demand_array() if factorize: self.decompose_technosphere() self.calculate_scores() def calculate_scores(self): self.supply_array = self.solve_linear_system() self.scores, self.contributions = {}, {} for name, array in self.category_index_arrays.items(): matrix = MatrixBuilder.build_diagonal_matrix(array, self.row_dict, 'INDEX', data_label='CONSTANT') self.scores[name] = float( (self.matrix * matrix * self.supply_array).sum()) def rebuild_matrix(self, vector): """Build a new technosphere matrix using the same row and column indices, but different values. Useful for Monte Carlo iteration or sensitivity analysis. Args: * *vector* (array): 1-dimensional NumPy array with length (# of technosphere parameters), in same order as ``self.tech_params``. Doesn't return anything, but overwrites ``self.technosphere_matrix``. """ self.matrix = SingleMatrixBuilder.build_single_matrix( self.params, self.row_dict, self.col_dict, vector) def redo_calculate(self, demand=None): """Redo LCI with same databases but different demand. Args: * *demand* (dict): A demand dictionary. Doesn't return anything, but overwrites ``self.demand_array``, ``self.supply_array``, and ``self.inventory``. .. warning:: If you want to redo the LCIA as well, use ``redo_lcia(demand)`` directly. """ assert hasattr(self, "matrix"), "Must do `calculate` first" if demand is not None: self.build_demand_array(demand) self.calculate_scores() self.logger.info("Redoing LCI", extra={'demand': str(demand or self.demand)})
class LCA(object): """A static LCI or LCIA calculation. Following the general philosophy of Brightway2, and good software practices, there is a clear separation of concerns between retrieving and formatting data and doing an LCA. Building the necessary matrices is done with MatrixBuilder objects (:ref:`matrixbuilders`). The LCA class only does the LCA calculations themselves. """ ############# ### Setup ### ############# def __init__(self, demand, method=None, weighting=None, normalization=None, database_filepath=None, log_config=None, presamples=None, seed=None, override_presamples_seed=False): """Create a new LCA calculation. Args: * *demand* (dict): The demand or functional unit. Needs to be a dictionary to indicate amounts, e.g. ``{("my database", "my process"): 2.5}``. * *method* (tuple, optional): LCIA Method tuple, e.g. ``("My", "great", "LCIA", "method")``. Can be omitted if only interested in calculating the life cycle inventory. Returns: A new LCA object """ if not isinstance(demand, Mapping): raise ValueError("Demand must be a dictionary") for key in demand: if not key: raise ValueError("Invalid demand dictionary") if log_config: create_logger(**log_config) self.logger = logging.getLogger('bw2calc') clean_databases() self._fixed = False self.demand = demand self.method = method self.normalization = normalization self.weighting = weighting self.database_filepath = database_filepath self.seed = seed if presamples and PackagesDataLoader is None: warnings.warn("Skipping presamples; `presamples` not installed") self.presamples = None elif presamples: # Iterating over a `Campaign` object will also return the presample filepaths self.presamples = PackagesDataLoader( dirpaths=presamples, seed=self.seed if override_presamples_seed else None, lca=self) else: self.presamples = None self.database_filepath, \ self.method_filepath, \ self.weighting_filepath, \ self.normalization_filepath = \ self.get_array_filepaths() self.logger.info("Created LCA object", extra={ 'demand': wrap_functional_unit(self.demand), 'database_filepath': self.database_filepath, 'method': self.method, 'method_filepath': self.method_filepath, 'normalization': self.normalization, 'normalization_filepath': self.normalization_filepath, 'presamples': str(self.presamples), 'weighting': self.weighting, 'weighting_filepath': self.weighting_filepath, }) def get_array_filepaths(self): """Use utility functions to get all array filepaths""" return ( get_filepaths(self.demand, "demand"), get_filepaths(self.method, "method"), get_filepaths(self.weighting, "weighting"), get_filepaths(self.normalization, "normalization"), ) def build_demand_array(self, demand=None): """Turn the demand dictionary into a *NumPy* array of correct size. Args: * *demand* (dict, optional): Demand dictionary. Optional, defaults to ``self.demand``. Returns: A 1-dimensional NumPy array """ demand = demand or self.demand self.demand_array = np.zeros(len(self.product_dict)) for key in demand: try: self.demand_array[self.product_dict[key]] = demand[key] except KeyError: if key in self.activity_dict: raise ValueError( (u"LCA can only be performed on products," u" not activities ({} is the wrong dimension)" ).format(key)) else: raise OutsideTechnosphere( "Can't find key {} in product dictionary".format(key)) ######################### ### Data manipulation ### ######################### def fix_dictionaries(self): """ Fix technosphere and biosphere dictionaries from this: .. code-block:: python {mapping integer id: matrix row/column index} To this: .. code-block:: python {(database, key): matrix row/column index} This isn't needed for the LCA calculation itself, but is helpful when interpreting results. Doesn't require any arguments or return anything, but changes ``self.activity_dict``, ``self.product_dict`` and ``self.biosphere_dict``. """ if self._fixed: # Already fixed - should be idempotent return False elif not mapping: # Don't have access to mapping return False rev_mapping = {v: k for k, v in mapping.items()} self._activity_dict = copy.deepcopy(self.activity_dict) self.activity_dict = { rev_mapping[k]: v for k, v in self.activity_dict.items() } self._product_dict = self.product_dict self.product_dict = { rev_mapping[k]: v for k, v in self.product_dict.items() } self._biosphere_dict = self.biosphere_dict self.biosphere_dict = { rev_mapping[k]: v for k, v in self.biosphere_dict.items() } self._fixed = True return True def reverse_dict(self): """Construct reverse dicts from technosphere and biosphere row and col indices to activity_dict/product_dict/biosphere_dict keys. Returns: (reversed ``self.activity_dict``, ``self.product_dict`` and ``self.biosphere_dict``) """ rev_activity = {v: k for k, v in self.activity_dict.items()} rev_product = {v: k for k, v in self.product_dict.items()} rev_bio = {v: k for k, v in self.biosphere_dict.items()} return rev_activity, rev_product, rev_bio ###################### ### Data retrieval ### ###################### def load_lci_data(self, fix_dictionaries=True, builder=TBMBuilder): """Load data and create technosphere and biosphere matrices.""" self._fixed = False self.bio_params, self.tech_params, \ self.biosphere_dict, self.activity_dict, \ self.product_dict, self.biosphere_matrix, \ self.technosphere_matrix = \ builder.build(self.database_filepath) if len(self.activity_dict) != len(self.product_dict): raise NonsquareTechnosphere(( "Technosphere matrix is not square: {} activities (columns) and {} products (rows). " "Use LeastSquaresLCA to solve this system, or fix the input " "data").format(len(self.activity_dict), len(self.product_dict))) if fix_dictionaries: self.fix_dictionaries() if not self.biosphere_dict: warnings.warn("No biosphere flows found. No inventory results can " "be calculated, `lcia` will raise an error") # Only need to index here for traditional LCA if self.presamples: self.presamples.index_arrays(self) self.presamples.update_matrices(matrices=('technosphere_matrix', 'biosphere_matrix')) def load_lcia_data(self, builder=MatrixBuilder): """Load data and create characterization matrix. This method will filter out regionalized characterization factors. This filtering needs access to ``bw2data`` - therefore, regionalized methods will cause incorrect results if ``bw2data`` is not importable. """ self.cf_params, _, _, self.characterization_matrix = builder.build( self.method_filepath, "amount", "flow", "row", row_dict=self._biosphere_dict, one_d=True, ) if global_index is not None: mask = self.cf_params['geo'] == global_index self.cf_params = self.cf_params[mask] self.characterization_matrix = builder.build_diagonal_matrix( self.cf_params, self._biosphere_dict, "row", "amount") if self.presamples: self.presamples.update_matrices( matrices=['characterization_matrix']) def load_normalization_data(self, builder=MatrixBuilder): """Load normalization data.""" self.normalization_params, _, _, self.normalization_matrix = \ builder.build( self.normalization_filepath, "amount", "flow", "index", row_dict=self._biosphere_dict, one_d=True ) if self.presamples: self.presamples.update_matrices(matrices=[ 'normalization_matrix', ]) def load_weighting_data(self): """Load weighting data, a 1-element array.""" self.weighting_params = load_arrays(self.weighting_filepath) self.weighting_value = self.weighting_params['amount'] # TODO: This won't work because weighting is a value not a matrix # if self.presamples: # self.presamples.update_matrices(self, ['weighting_value',]) #################### ### Calculations ### #################### def decompose_technosphere(self): """ Factorize the technosphere matrix into lower and upper triangular matrices, :math:`A=LU`. Does not solve the linear system :math:`Ax=B`. Doesn't return anything, but creates ``self.solver``. .. warning:: Incorrect results could occur if a technosphere matrix was factorized, and then a new technosphere matrix was constructed, as ``self.solver`` would still be the factorized older technosphere matrix. You are responsible for deleting ``self.solver`` when doing these types of advanced calculations. """ self.solver = factorized(self.technosphere_matrix.tocsc()) def solve_linear_system(self): """ Master solution function for linear system :math:`Ax=B`. To most numerical analysts, matrix inversion is a sin. -- Nicolas Higham, Accuracy and Stability of Numerical Algorithms, Society for Industrial and Applied Mathematics, Philadelphia, PA, USA, 2002, p. 260. We use `UMFpack <http://www.cise.ufl.edu/research/sparse/umfpack/>`_, which is a very fast solver for sparse matrices. If the technosphere matrix has already been factorized, then the decomposed technosphere (``self.solver``) is reused. Otherwise the calculation is redone completely. """ if hasattr(self, "solver"): return self.solver(self.demand_array) else: return spsolve(self.technosphere_matrix, self.demand_array) def lci(self, factorize=False, builder=TBMBuilder): """ Calculate a life cycle inventory. #. Load LCI data, and construct the technosphere and biosphere matrices. #. Build the demand array #. Solve the linear system to get the supply array and life cycle inventory. Args: * *factorize* (bool, optional): Factorize the technosphere matrix. Makes additional calculations with the same technosphere matrix much faster. Default is ``False``; not useful is only doing one LCI calculation. * *builder* (``MatrixBuilder`` object, optional): Default is ``bw2calc.matrices.TechnosphereBiosphereMatrixBuilder``, which is fine for most cases. Custom matrix builders can be used to manipulate data in creative ways before building the matrices. .. warning:: Custom matrix builders should inherit from ``TechnosphereBiosphereMatrixBuilder``, because technosphere inputs need to have their signs flipped to be negative, as we do :math:`A^{-1}f` directly instead of :math:`(I - A^{-1})f`. Doesn't return anything, but creates ``self.supply_array`` and ``self.inventory``. """ self.load_lci_data(builder=builder) self.build_demand_array() if factorize: self.decompose_technosphere() self.lci_calculation() def lci_calculation(self): """The actual LCI calculation. Separated from ``lci`` to be reusable in cases where the matrices are already built, e.g. ``redo_lci`` and Monte Carlo classes. """ self.supply_array = self.solve_linear_system() # Turn 1-d array into diagonal matrix count = len(self.activity_dict) self.inventory = self.biosphere_matrix * \ sparse.spdiags([self.supply_array], [0], count, count) def lcia(self, builder=MatrixBuilder): """ Calculate the life cycle impact assessment. #. Load and construct the characterization matrix #. Multiply the characterization matrix by the life cycle inventory Args: * *builder* (``MatrixBuilder`` object, optional): Default is ``bw2calc.matrices.MatrixBuilder``, which is fine for most cases. Custom matrix builders can be used to manipulate data in creative ways before building the characterization matrix. Doesn't return anything, but creates ``self.characterized_inventory``. """ assert hasattr(self, "inventory"), "Must do lci first" assert self.method, "Must specify a method to perform LCIA" if not self.biosphere_dict: raise EmptyBiosphere self.load_lcia_data(builder) self.lcia_calculation() def lcia_calculation(self): """The actual LCIA calculation. Separated from ``lcia`` to be reusable in cases where the matrices are already built, e.g. ``redo_lcia`` and Monte Carlo classes. """ self.characterized_inventory = \ self.characterization_matrix * self.inventory def normalize(self): """Multiply characterized inventory by flow-specific normalization factors.""" assert hasattr(self, "characterized_inventory"), "Must do lcia first" if not hasattr(self, "normalization_matrix"): self.load_normalization_data() self.normalization_calculation() def normalization_calculation(self): """The actual normalization calculation. Creates ``self.normalized_inventory``.""" self.normalized_inventory = \ self.normalization_matrix * self.characterized_inventory def weight(self): """Multiply characterized inventory by weighting value. Can be done with or without normalization.""" assert hasattr(self, "characterized_inventory"), "Must do lcia first" if not hasattr(self, "weighting_value"): self.load_weighting_data() def weighting_calculation(self): """The actual weighting calculation. Multiples weighting value by normalized inventory, if available, otherwise by characterized inventory. Creates ``self.weighted_inventory``.""" if hasattr(self, "normalized_inventory"): obj = self.normalized_inventory else: obj = self.characterized_inventory self.weighted_inventory = self.weighting_value[0] * obj @property def score(self): """ The LCIA score as a ``float``. Note that this is a `property <http://docs.python.org/2/library/functions.html#property>`_, so it is ``foo.lca``, not ``foo.score()`` """ assert hasattr(self, "characterized_inventory"), "Must do LCIA first" if self.weighting: assert hasattr(self, "weighted_inventory"), "Must do weighting first" return float(self.weighted_inventory.sum()) return float(self.characterized_inventory.sum()) ######################### ### Redo calculations ### ######################### def rebuild_technosphere_matrix(self, vector): """Build a new technosphere matrix using the same row and column indices, but different values. Useful for Monte Carlo iteration or sensitivity analysis. Args: * *vector* (array): 1-dimensional NumPy array with length (# of technosphere parameters), in same order as ``self.tech_params``. Doesn't return anything, but overwrites ``self.technosphere_matrix``. """ self.technosphere_matrix = MatrixBuilder.build_matrix( self.tech_params, self._activity_dict, self._product_dict, "row", "col", new_data=TBMBuilder.fix_supply_use(self.tech_params, vector.copy())) def rebuild_biosphere_matrix(self, vector): """Build a new biosphere matrix using the same row and column indices, but different values. Useful for Monte Carlo iteration or sensitivity analysis. Args: * *vector* (array): 1-dimensional NumPy array with length (# of biosphere parameters), in same order as ``self.bio_params``. Doesn't return anything, but overwrites ``self.biosphere_matrix``. """ self.biosphere_matrix = MatrixBuilder.build_matrix( self.bio_params, self._biosphere_dict, self._activity_dict, "row", "col", new_data=vector) def rebuild_characterization_matrix(self, vector): """Build a new characterization matrix using the same row and column indices, but different values. Useful for Monte Carlo iteration or sensitivity analysis. Args: * *vector* (array): 1-dimensional NumPy array with length (# of characterization parameters), in same order as ``self.cf_params``. Doesn't return anything, but overwrites ``self.characterization_matrix``. """ self.characterization_matrix = MatrixBuilder.build_diagonal_matrix( self.cf_params, self._biosphere_dict, "row", "row", new_data=vector) def switch_method(self, method): """Switch to LCIA method `method`""" self.method = method _, self.method_filepath, _, _ = self.get_array_filepaths() self.load_lcia_data() self.logger.info("Switching LCIA method", extra={'method': method}) def switch_normalization(self, normalization): """Switch to LCIA normalization `normalization`""" self.normalization = normalization _, _, _, self.normalization_filepath = self.get_array_filepaths() self.load_normalization_data() self.logger.info("Switching LCIA normalization", extra={'normalization': normalization}) def switch_weighting(self, weighting): """Switch to LCIA weighting `weighting`""" self.weighting = weighting _, _, self.weighting_filepath, _ = self.get_array_filepaths() self.load_weighting_data() self.logger.info("Switching LCIA weighting", extra={'weighting': weighting}) def redo_lci(self, demand=None): """Redo LCI with same databases but different demand. Args: * *demand* (dict): A demand dictionary. Doesn't return anything, but overwrites ``self.demand_array``, ``self.supply_array``, and ``self.inventory``. .. warning:: If you want to redo the LCIA as well, use ``redo_lcia(demand)`` directly. """ assert hasattr(self, "inventory"), "Must do lci first" if demand is not None: self.build_demand_array(demand) self.demand = demand self.lci_calculation() self.logger.info( "Redoing LCI", extra={'demand': wrap_functional_unit(demand or self.demand)}) def redo_lcia(self, demand=None): """Redo LCIA, optionally with new demand. Args: * *demand* (dict, optional): New demand dictionary. Optional, defaults to ``self.demand``. Doesn't return anything, but overwrites ``self.characterized_inventory``. If ``demand`` is given, also overwrites ``self.demand_array``, ``self.supply_array``, and ``self.inventory``. """ assert hasattr(self, "characterized_inventory"), "Must do LCIA first" if demand is not None: self.redo_lci(demand) self.demand = demand self.lcia_calculation() self.logger.info( "Redoing LCIA", extra={'demand': wrap_functional_unit(demand or self.demand)}) def to_dataframe(self, cutoff=200): """Return all nonzero elements of characterized inventory as Pandas dataframe""" assert mapping, "This method doesn't work with independent LCAs" assert pandas, "This method requires the `pandas` (http://pandas.pydata.org/) library" assert hasattr( self, "characterized_inventory"), "Must do LCIA calculation first" from bw2data import get_activity coo = self.characterized_inventory.tocoo() stacked = np.vstack([np.abs(coo.data), coo.row, coo.col, coo.data]) stacked.sort() rev_activity, _, rev_bio = self.reverse_dict() length = stacked.shape[1] data = [] for x in range(min(cutoff, length)): if stacked[3, length - x - 1] == 0.: continue activity = get_activity(rev_activity[stacked[2, length - x - 1]]) flow = get_activity(rev_bio[stacked[1, length - x - 1]]) data.append((activity['name'], flow['name'], activity.get('location'), stacked[3, length - x - 1])) return pandas.DataFrame( data, columns=['Activity', 'Flow', 'Region', 'Amount']) #################### ### Contribution ### #################### def top_emissions(self, **kwargs): """Call ``bw2analyzer.ContributionAnalyses.annotated_top_emissions``""" try: from bw2analyzer import ContributionAnalysis except ImportError: raise ImportError("`bw2analyzer` is not installed") return ContributionAnalysis().annotated_top_emissions(self, **kwargs) def top_activities(self, **kwargs): """Call ``bw2analyzer.ContributionAnalyses.annotated_top_processes``""" try: from bw2analyzer import ContributionAnalysis except ImportError: raise ImportError("`bw2analyzer` is not installed") return ContributionAnalysis().annotated_top_processes(self, **kwargs)