Source code for VESIcal.model_classes

from abc import abstractmethod
import numpy as np
import pandas as pd
import warnings as w
from scipy.optimize import root_scalar
from scipy.optimize import root
from copy import deepcopy

from VESIcal import activity_models
from VESIcal import core
from VESIcal import fugacity_models


[docs] class Model(object): """The model object implements a volatile solubility model. It is composed of the methods needed to evaluate :func:`VESIcal.calculate_dissolved_volatiles`, :func:`VESIcal.calculate_equilibrium_fluid_comp`, and :func:`calculate_saturation_pressure`. The fugacity and activity models for the volatiles species must be specified, defaulting to ideal. """ def __init__(self): self.set_volatile_species(None) self.set_fugacity_model(fugacity_models.fugacity_idealgas()) self.set_activity_model(activity_models.activity_idealsolution()) self.set_calibration_ranges([]) self.set_solubility_dependence(False) def set_volatile_species(self, volatile_species): if type(volatile_species) == str: volatile_species = [volatile_species] elif type(volatile_species) != list: raise core.InputError("volatile_species must be a str or list.") self.volatile_species = volatile_species def set_fugacity_model(self, fugacity_model): self.fugacity_model = fugacity_model def set_activity_model(self, activity_model): self.activity_model = activity_model def set_calibration_ranges(self, calibration_ranges): self.calibration_ranges = calibration_ranges def set_solubility_dependence(self, solubility_dependence): self.solubility_dependence = solubility_dependence
[docs] def get_calibration_values(self, variable_names): """ Returns the values stored as the calibration range for the given variable(s). However, for checks where there is a single value- i.e. cr_GreaterThan or crf_LessThan, the logical operation will remain a mystery until someone figures out an elegant way of communicating it. Parameters ---------- variable_names str or list The name(s) of the variables you want the calibration ranges for. Returns ------- list A list of values or tuples for the calibration ranges in the order given. """ # Check if the variable name is passed as a string, and if so put it in # a list: if type(variable_names) == str: variable_names = [variable_names] calibration_values = [] for var in variable_names: found_var = False for cr in self.calibration_ranges: if found_var is False: if cr.parameter_name == var: found_var = True calibration_values.append(cr.value) if found_var is False: calibration_values.append(np.nan) return calibration_values
@abstractmethod def calculate_dissolved_volatiles(self, **kwargs): pass @abstractmethod def calculate_equilibrium_fluid_comp(self, **kwargs): pass @abstractmethod def calculate_saturation_pressure(self, **kwargs): pass # @abstractmethod # def preprocess_sample(self,**kwargs): # pass
[docs] def check_calibration_range(self, parameters, report_nonexistance=True): """ Checks whether the given parameters are within the ranges defined by the CalibrationRange objects for the model and its fugacity and activity models. An empty string will be returned if all parameters are within the calibration range. If a parameter is not within the calibration range, a description of the problem will be returned in the string. Parameters ---------- parameters dict Dictionary keys are the names of the parameters to be checked, e.g., pressure temperature, SiO2, etc. Values are the values of each parameter. A complete set need not be given. Returns ------- str String description of any parameters falling outside of the calibration range. """ s = '' for cr in self.calibration_ranges: if cr.check(parameters) is False: s += cr.string(parameters, report_nonexistance) for cr in self.fugacity_model.calibration_ranges: if cr.check(parameters) is False: s += cr.string(parameters, report_nonexistance) for cr in self.activity_model.calibration_ranges: if cr.check(parameters) is False: s += cr.string(parameters, report_nonexistance) return s
[docs] def get_calibration_range(self): """ Returns a string describing the calibration ranges defined by the CalibrationRange objects for each model, and its associated fugacity and activity models. Returns ------- str String description of the calibration range objects.""" s = '' for cr in self.calibration_ranges: s += cr.string(None) for cr in self.fugacity_model.calibration_ranges: s += cr.string(None) for cr in self.activity_model.calibration_ranges: s += cr.string(None) return s
# ------------ MIXED FLUID MODELS ------------------------------- #
[docs] class MixedFluid(Model): """ Implements the generic framework for mixed fluid solubility. Any set of pure fluid solubility models may be specified. """ def __init__(self, models): """ Initializes the mixed fluid model. Parameters ---------- models dictionary Dictionary with names of volatile species as keys, and the model objects as values. """ self.models = tuple(model for model in models.values()) self.set_volatile_species(list(models.keys()))
[docs] def get_calibration_values(self, variable_names): """ Placeholder method to prevent an error when this generic method is called for a MixedFluid model. Returns ------- np.nan """ return np.nan
[docs] def calculate_dissolved_volatiles(self, pressure, X_fluid, returndict=False, **kwargs): """ Calculates the dissolved volatile concentrations in wt%, using each model's calculate_dissolved_volatiles method. At present the volatile concentrations are not propagated through. Parameters ---------- pressure float The total pressure in bars. X_fluid float, numpy.ndarry, dict, pandas Series The mole fraction of each species in the fluid. If the mixed fluid model contains only two species (e.g. CO2 and H2O), the value of the first species in self.volatile_species may be passed on its own as a float. returndict bool If True, the results will be returned in a dict, otherwise they will be returned as a tuple. Returns ------- tuple Dissolved volatile concentrations of each species in the model, in the order set by self.volatile_species. """ if ((type(X_fluid) == float or type(X_fluid) == int) and len(self.volatile_species) == 2): X_fluid = (X_fluid, 1-X_fluid) elif len(X_fluid) != len(self.volatile_species): raise core.InputError("X_fluid must have the same length as the " "number of volatile species in the " "MixedFluids Model class, or it may have " "length 1 if two species are present in " "the MixedFluids Model class.") if np.sum(X_fluid) != 1.0: raise core.InputError("X_fluid must sum to 1.0") if any(val < 0 for val in X_fluid) or any(val > 1 for val in X_fluid): raise core.InputError("Each mole fraction in X_fluid must have a " "value between 0 and 1.") if type(X_fluid) == dict or type(X_fluid) == pd.core.series.Series: X_fluid = tuple(X_fluid[species] for species in self.volatile_species) # If the models don't depend on the concentration of volatiles, # themselves: if all(model.solubility_dependence is False for model in self.models): result = tuple(model.calculate_dissolved_volatiles( pressure=pressure, X_fluid=Xi, **kwargs) for model, Xi in zip(self.models, X_fluid)) # If one of the models depends on the other volatile concentration elif (len(self.models) == 2 and self.models[0].solubility_dependence is False and 'sample' in kwargs): result0 = self.models[0].calculate_dissolved_volatiles( pressure=pressure, X_fluid=X_fluid[0], **kwargs) samplecopy = kwargs['sample'].change_composition( {self.volatile_species[0]: result0}, inplace=False) kwargs['sample'] = samplecopy result1 = self.models[1].calculate_dissolved_volatiles( pressure=pressure, X_fluid=X_fluid[1], **kwargs) result = (result0, result1) elif(len(self.models) == 2 and self.models[1].solubility_dependence is False and 'sample' in kwargs): result1 = self.models[1].calculate_dissolved_volatiles( pressure=pressure, X_fluid=X_fluid[1], **kwargs) samplecopy = kwargs['sample'].change_composition( {self.volatile_species[1]: result1}, inplace=False) kwargs['sample'] = samplecopy result0 = self.models[0].calculate_dissolved_volatiles( pressure=pressure, X_fluid=X_fluid[0], **kwargs) result = (result0, result1) else: raise core.InputError("The solubility dependence of the models " "is not currently supported by the " "MixedFluid model.") if returndict: resultsdict = {} for i, v in zip(range(len(self.volatile_species)), self.volatile_species): resultsdict.update({v+'_liq': result[i]}) return resultsdict else: return result
[docs] def calculate_equilibrium_fluid_comp(self, pressure, sample, return_dict=True, **kwargs): """ Calculates the composition of the fluid in equilibrium with the dissolved volatile concentrations passed. If a fluid phase is undersaturated at the chosen pressure (0,0) will be returned. Note, this currently assumes the given H2O and CO2 concentrations are the system total, not the total dissolved. If one of the volatile species has a zero or negative concentration, the pure fluid model for the other volatile species will be used. Parameters ---------- pressure float The total pressure in bars. sample Sample class Magma major element composition. return_dict bool Set the return type, if true a dict will be returned, if False two floats will be returned. Default is True. Returns ------- dict or floats Mole fractions of the volatile species in the fluid, in the order given by self.volatile_species if floats. """ if len(self.volatile_species) != 2: raise core.InputError("Currently equilibrium fluid compositions " "can only be calculated when two volatile " "species are present.") dissolved_at_0bar = [self.models[0].calculate_dissolved_volatiles( sample=sample, pressure=0.0, **kwargs), self.models[1].calculate_dissolved_volatiles( sample=sample, pressure=0.0, **kwargs)] if (sample.get_composition(self.volatile_species[0]) <= 0.0 or (sample.get_composition(self.volatile_species[0]) <= dissolved_at_0bar[0])): Xv0 = 0.0 Xv1 = self.models[1].calculate_equilibrium_fluid_comp( pressure=pressure, sample=sample, **kwargs) elif (sample.get_composition(self.volatile_species[1]) <= 0.0 or (sample.get_composition(self.volatile_species[1]) <= dissolved_at_0bar[1])): Xv1 = 0.0 Xv0 = self.models[0].calculate_equilibrium_fluid_comp( pressure=pressure, sample=sample, **kwargs) else: satP = self.calculate_saturation_pressure(sample, **kwargs) if satP < pressure: if return_dict: return {self.volatile_species[0]: 0, self.volatile_species[1]: 0} else: return (0, 0) molfracs = sample.get_composition(units='mol_oxides') (Xt0, Xt1) = (molfracs[self.volatile_species[0]], molfracs[self.volatile_species[1]]) try: Xv0 = root_scalar(self.root_for_fluid_comp, bracket=[1e-15, 1-1e-15], args=(pressure, Xt0, Xt1, sample, kwargs)).root Xv1 = 1 - Xv0 except Exception: try: Xv0 = root_scalar(self.root_for_fluid_comp, x0=0.5, x1=0.1, args=(pressure, Xt0, Xt1, sample, kwargs)).root Xv1 = 1 - Xv0 except Exception: raise core.SaturationError("Equilibrium fluid not found. " "Likely an issue with the " "numerical solver.") if return_dict: return {self.volatile_species[0]: Xv0, self.volatile_species[1]: Xv1} else: return Xv0, Xv1
[docs] def calculate_saturation_pressure(self, sample, **kwargs): """ Calculates the pressure at which a fluid will be saturated, given the dissolved volatile concentrations. If one of the volatile species has a zero or negative concentration the pure fluid model for the other species will be used. If one of the volatile species has a concentration lower than the concentration dissolved at 0 bar, the pure fluid model for the other species will be used. Parameters ---------- sample Sample class Magma major element composition (including volatiles). Returns ------- float The saturation pressure in bars. """ dissolved_at_0bar = [self.models[0].calculate_dissolved_volatiles( sample=sample, pressure=0.0, **kwargs), self.models[1].calculate_dissolved_volatiles( sample=sample, pressure=0.0, **kwargs)] if (sample.get_composition(self.volatile_species[0]) <= 0.0 or (sample.get_composition(self.volatile_species[0]) <= dissolved_at_0bar[0])): satP = self.models[1].calculate_saturation_pressure(sample=sample, **kwargs) elif (sample.get_composition(self.volatile_species[1]) <= 0.0 or (sample.get_composition(self.volatile_species[1]) <= dissolved_at_0bar[1])): satP = self.models[0].calculate_saturation_pressure(sample=sample, **kwargs) else: volatile_concs = np.array(tuple(sample.get_composition(species) for species in self.volatile_species)) x0 = 0 for model in self.models: xx0 = model.calculate_saturation_pressure(sample=sample, **kwargs) if np.isfinite(xx0): x0 += xx0 try: satP = root(self.root_saturation_pressure, x0=[x0, 0.5], args=(volatile_concs, sample, kwargs)).x[0] except Exception: w.warn("Saturation pressure not found.", RuntimeWarning, stacklevel=2) satP = np.nan return satP
[docs] def calculate_isobars_and_isopleths(self, pressure_list, isopleth_list=[0, 1], points=51, return_dfs=True, extend_to_zero=True, **kwargs): """ Calculates isobars and isopleths. Isobars can be calculated for any number of pressures. Variables required by each of the pure fluid models must be passed, e.g. sample, temperature, etc. Parameters ---------- pressure_list list or float List of all pressure values at which to calculate isobars, in bars. isopleth_list list Default value is None, in which case only isobars will be calculated. List of all fluid compositions in mole fraction (of the first species in self.volatile_species) at which to calcualte isopleths. Values can range from 0 to 1. points int The number of points in each isobar and isopleth. Default value is 101. return_dfs bool If True, the results will be returned as two pandas DataFrames, as produced by the MagmaSat method. If False the results will be returned as lists of numpy arrays. Returns ------- pandas DataFrame object(s) or list(s) If isopleth_list is not None, two objects will be returned, one with the isobars and the second withthe isopleths. If return_dfs is True, two pandas DataFrames will be returned with column names 'Pressure' or 'XH2O_fl', 'H2O_liq', and 'CO2_liq'. If return_dfs is False, two lists of numpy arrays will be returned. Each array is an individual isobar or isopleth, in the order passed via pressure_list or isopleth_list. The arrays are the concentrations of H2O and CO2 in the liquid, in the order of the species in self.volatile_species. """ if (len(self.volatile_species) != 2 or 'H2O' not in self.volatile_species or 'CO2' not in self.volatile_species): raise core.InputError("calculate_isobars_and_isopleths may only " "be used with a H2O-CO2 fluid.") H2O_id = self.volatile_species.index('H2O') CO2_id = self.volatile_species.index('CO2') if isinstance(pressure_list, list): pass elif (isinstance(pressure_list, int) or isinstance(pressure_list, float)): pressure_list = [pressure_list] else: raise core.InputError("pressure_list must be a single float " "(1000.0), int (1000), or list of those " "[1000, 2000.0, 3000].") has_isopleths = True if isopleth_list is None: has_isopleths = False isobar_dfs = [] # Prepare to accumulate DataFrames for isobars isobars = [] # This is only used as a return if return_dfs is set to False for pressure in pressure_list: dissolved = np.zeros([2, points]) Xv0 = np.linspace(0.0, 1.0, points) for i in range(points): dissolved[:, i] = self.calculate_dissolved_volatiles( pressure=pressure, X_fluid=(Xv0[i], 1-Xv0[i]), **kwargs) row_df = pd.DataFrame({ 'Pressure': [pressure], 'H2O_liq': [dissolved[H2O_id, i]], 'CO2_liq': [dissolved[CO2_id, i]] }) isobar_dfs.append(row_df) isobars.append(dissolved) # Concatenate all DataFrames for isobars isobars_df = pd.concat(isobar_dfs, ignore_index=True) if has_isopleths: isopleth_dfs = [] isopleths = [] for isopleth in isopleth_list: dissolved = np.zeros([2, points]) pmin = np.nanmin(pressure_list) pmax = np.nanmax(pressure_list) if pmin == pmax: pmin = 0.0 pressures = np.linspace(pmin, pmax, points) for i in range(points): dissolved[:, i] = self.calculate_dissolved_volatiles( pressure=pressures[i], X_fluid=(isopleth, 1-isopleth), **kwargs) row_df = pd.DataFrame({ 'XH2O_fl': [[isopleth, 1-isopleth][H2O_id]], 'H2O_liq': [dissolved[H2O_id, i]], 'CO2_liq': [dissolved[CO2_id, i]] }) isopleth_dfs.append(row_df) isopleths.append(dissolved) # Concatenate all DataFrames for isopleths isopleths_df = pd.concat(isopleth_dfs, ignore_index=True) if return_dfs: if has_isopleths: return (isobars_df, isopleths_df) else: return isobars_df else: if has_isopleths: return (isobars, isopleths) else: return isobars
[docs] def calculate_degassing_path(self, sample, pressure='saturation', fractionate_vapor=0.0, final_pressure=100.0, steps=101, return_dfs=True, round_to_zero=True, **kwargs): """ Calculates the dissolved volatiles in a progressively degassing sample. Parameters ---------- sample Sample class Magma major element composition (including volatiles). pressure string, float, int, list, or numpy array Defaults to 'saturation', the calculation will begin at the saturation pressure. If a number is passed as either a float or int, this will be the starting pressure. If a list of numpy array is passed, the pressure values in the list or array will define the degassing path, i.e. final_pressure and steps variables will be ignored. Units are bars. fractionate_vapor float What proportion of vapor should be removed at each step. If 0.0 (default), the degassing path will correspond to closed-system degassing. If 1.0, the degassing path will correspond to open-system degassing. final_pressure float The final pressure on the degassing path, in bars. Ignored if a list or numpy array is passed as the pressure variable. Default is 1 bar. steps int The number of steps in the degassing path. Ignored if a list or numpy array are passed as the pressure variable. return_dfs bool If True, the results will be returned in a pandas DataFrame, if False, two numpy arrays will be returned. round_to_zero bool If True, the first entry of FluidProportion_wt will be rounded to zero, rather than being a value within numerical error of zero. Default is True. Returns ------- pandas DataFrame or numpy arrays If return_dfs is True (default), a DataFrame with columns 'Pressure_bars', 'H2O_liq', 'CO2_liq', 'H2O_fl', 'CO2_fl', and 'FluidProportion_wt', is returned. Dissolved volatiles are in wt%, the proportions of volatiles in the fluid are in mole fraction. Otherwise a numpy array containing the dissolved volatile concentrations, and a numpy array containing the mole fractions of volatiles in the fluid is returned. The columns are in the order of the volatiles in self.volatile_species. """ # Create a copy of the sample so that initial volatile concentrations # are not overwritten. sample = deepcopy(sample) # Its imperative that normalization doesn't change the volatile # concentrations throughout the calculation. if sample.default_normalization not in ['fixedvolatiles', 'none']: sample.set_default_normalization('fixedvolatiles') w.warn('Sample normalization changed to fixedvolatiles.') wtptoxides = sample.get_composition(units='wtpt_oxides') wtm0s, wtm1s = (wtptoxides[self.volatile_species[0]], wtptoxides[self.volatile_species[1]]) if isinstance(pressure, str): if pressure == 'saturation': p0 = self.calculate_saturation_pressure(sample, **kwargs) pressures = np.linspace(p0, final_pressure, steps) else: raise core.InputError("Pressure string not understood.") elif type(pressure) == float or type(pressure) == int: pressures = np.linspace(pressure, final_pressure, steps) elif type(pressure) == list or type(pressure) == np.ndarray: pressures = pressure Xv = np.zeros([2, len(pressures)]) wtm = np.zeros([2, len(pressures)]) for i in range(len(pressures)): try: wtptoxides = sample.get_composition(units='wtpt_oxides') X_fluid = self.calculate_equilibrium_fluid_comp( pressure=pressures[i], sample=sample, return_dict=False, **kwargs) Xv[:, i] = X_fluid if X_fluid == (0, 0): wtm[:, i] = (wtptoxides[self.volatile_species[0]], wtptoxides[self.volatile_species[1]]) else: if X_fluid[0] == 0: wtm[0, i] = wtptoxides[self.volatile_species[0]] wtm[1, i] = self.calculate_dissolved_volatiles( pressure=pressures[i], sample=sample, X_fluid=X_fluid, **kwargs)[1] elif X_fluid[1] == 0: wtm[1, i] = wtptoxides[self.volatile_species[1]] wtm[0, i] = self.calculate_dissolved_volatiles( pressure=pressures[i], sample=sample, X_fluid=X_fluid, **kwargs)[0] else: wtm[:, i] = self.calculate_dissolved_volatiles( pressure=pressures[i], sample=sample, X_fluid=X_fluid, **kwargs) sample.change_composition({ self.volatile_species[0]: (wtm[0, i] + (1-fractionate_vapor) * (wtm0s-wtm[0, i])), self.volatile_species[1]: (wtm[1, i] + (1-fractionate_vapor) * (wtm1s-wtm[1, i]))}) except Exception: Xv[:, i] = [np.nan]*np.shape(Xv)[0] wtm[:, i] = wtm[:, i-1] if return_dfs: exsolved_degassing_df = pd.DataFrame() exsolved_degassing_df['Pressure_bars'] = pressures exsolved_degassing_df['H2O_liq'] = ( wtm[self.volatile_species.index('H2O'), :]) exsolved_degassing_df['CO2_liq'] = ( wtm[self.volatile_species.index('CO2'), :]) exsolved_degassing_df['H2O_fl'] = ( Xv[self.volatile_species.index('H2O'), :]) exsolved_degassing_df['CO2_fl'] = ( Xv[self.volatile_species.index('CO2'), :]) exsolved_degassing_df['FluidProportion_wt'] = ( (wtm0s+wtm1s) - exsolved_degassing_df['H2O_liq'] - exsolved_degassing_df['CO2_liq']) if (round_to_zero is True and np.round( exsolved_degassing_df.loc[0, 'FluidProportion_wt'], 2) == 0): exsolved_degassing_df.loc[0, 'FluidProportion_wt'] = 0.0 return exsolved_degassing_df else: return (wtm, Xv)
[docs] def root_saturation_pressure(self, x, volatile_concs, sample, kwargs): """ Function called by scipy.root when finding the saturation pressure using calculate_saturation_pressure. Parameters ---------- x numpy array The guessed value for the root. x[0] is the pressure (in bars) and x[1] is the mole fraction of the first volatile in self.volatile_species. volatile_concs numpy array The dissolved volatile concentrations, in the same order as self.volatile_species. sample: Sample class Magma major element composition (including volatiles). kwargs dictionary Dictionary of keyword arguments, which may be required by the pure-fluid models. Returns ------- numpy array The difference in the dissolved volatile concentrations, and those predicted with the pressure and fluid composition specified by x. """ if x[1] < 0: x[1] = 0 elif x[1] > 1: x[1] = 1 if x[0] <= 0: x[0] = 1e-15 misfit = (np.array(self.calculate_dissolved_volatiles( pressure=x[0], X_fluid=(x[1], 1-x[1]), sample=sample, **kwargs)) - volatile_concs) return misfit
[docs] def root_for_fluid_comp(self, Xv0, pressure, Xt0, Xt1, sample, kwargs): """ Function called by scipy.root_scalar when calculating the composition of equilibrium fluid in the calculate_equilibrium_fluid_comp method. Parameters ---------- Xv0 float The guessed mole fraction of the first volatile species in self.volatile_species. pressure float The total pressure in bars. Xt0 float The total mole fraction of the first volatile species in self.volatile_species. Xt1 float The total mole fraction of the second volatile species in self.volatile_species. sample Sample class Magma major element composition. kwargs dictionary A dictionary of keyword arguments that may be required by the pure fluid models. Returns ------- float The differene in the LHS and RHS of the mass balance equation. Eq X in manuscript. """ wtt0 = sample.get_composition(self.volatile_species[0]) wtt1 = sample.get_composition(self.volatile_species[1]) wtm0, wtm1 = self.calculate_dissolved_volatiles( pressure=pressure, X_fluid=(Xv0, 1-Xv0), sample=sample, **kwargs) Xm0 = Xt0 / wtt0 * wtm0 Xm1 = Xt1 / wtt1 * wtm1 if self.volatile_species[0] == 'CO2' and Xv0 != Xm0: f = (Xt0 - Xm0) / (Xv0 - Xm0) return (1 - f) * Xm1 + f * (1 - Xv0) - Xt1 else: f = (Xt1 - Xm1) / ((1 - Xv0) - Xm1) return (1 - f) * Xm0 + f * Xv0 - Xt0
[docs] def check_calibration_range(self, parameters, report_nonexistance=True): """ Checks whether the given parameters are within the ranges defined by the CalibrationRange objects for each model and its fugacity and activity models. An empty string will be returned if all parameters are within the calibration range. If a parameter is not within the calibration range, a description of the problem will be returned in the string. Parameters ---------- parameters dict Dictionary keys are the names of the parameters to be checked, e.g., pressure temperature, SiO2, etc. Values are the values of each parameter. A complete set need not be given. Returns ------- str String description of any parameters falling outside of the calibration range. """ s = '' for model in self.models: for cr in model.calibration_ranges: if cr.check(parameters) is False: s += cr.string(parameters, report_nonexistance) for cr in model.fugacity_model.calibration_ranges: if cr.check(parameters) is False: s += cr.string(parameters, report_nonexistance) for cr in model.activity_model.calibration_ranges: if cr.check(parameters) is False: s += cr.string(parameters, report_nonexistance) return s
[docs] def get_calibration_range(self): """ Returns a string describing the calibration ranges defined by the CalibrationRange objects for each model, and its associated fugacity and activity models. Returns ------- str String description of the calibration range objects.""" s = '' for model in self.models: for cr in model.calibration_ranges: s += cr.string(None) for cr in model.fugacity_model.calibration_ranges: s += cr.string(None) for cr in model.activity_model.calibration_ranges: s += cr.string(None) return s