source: sasview/src/sas/qtgui/Perspectives/Fitting/FittingUtilities.py @ 88ada06

ESS_GUIESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_iss879ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalc
Last change on this file since 88ada06 was 88ada06, checked in by Torin Cooper-Bennun <torin.cooper-bennun@…>, 10 months ago

support fixed-choice shell parameters (e.g. spherical_sld.shape[n])

  • Property mode set to 100644
File size: 24.4 KB
Line 
1import copy
2
3from PyQt5 import QtCore
4from PyQt5 import QtGui
5
6import numpy
7
8from sas.qtgui.Plotting.PlotterData import Data1D
9from sas.qtgui.Plotting.PlotterData import Data2D
10
11from sas.qtgui.Perspectives.Fitting.AssociatedComboBox import AssociatedComboBox
12
13model_header_captions = ['Parameter', 'Value', 'Min', 'Max', 'Units']
14
15model_header_tooltips = ['Select parameter for fitting',
16                         'Enter parameter value',
17                         'Enter minimum value for parameter',
18                         'Enter maximum value for parameter',
19                         'Unit of the parameter']
20
21poly_header_captions = ['Parameter', 'PD[ratio]', 'Min', 'Max', 'Npts', 'Nsigs',
22                        'Function', 'Filename']
23
24poly_header_tooltips = ['Select parameter for fitting',
25                        'Enter polydispersity ratio (STD/mean). '
26                        'STD: standard deviation from the mean value',
27                        'Enter minimum value for parameter',
28                        'Enter maximum value for parameter',
29                        'Enter number of points for parameter',
30                        'Enter number of sigmas parameter',
31                        'Select distribution function',
32                        'Select filename with user-definable distribution']
33
34error_tooltip = 'Error value for fitted parameter'
35header_error_caption = 'Error'
36
37def replaceShellName(param_name, value):
38    """
39    Updates parameter name from <param_name>[n_shell] to <param_name>value
40    """
41    assert '[' in param_name
42    return param_name[:param_name.index('[')]+str(value)
43
44def getIterParams(model):
45    """
46    Returns a list of all multi-shell parameters in 'model'
47    """
48    return list([par for par in model.iq_parameters if "[" in par.name])
49
50def getMultiplicity(model):
51    """
52    Finds out if 'model' has multishell parameters.
53    If so, returns the name of the counter parameter and the number of shells
54    """
55    iter_params = getIterParams(model)
56    param_name = ""
57    param_length = 0
58    if iter_params:
59        param_length = iter_params[0].length
60        param_name = iter_params[0].length_control
61        if param_name is None and '[' in iter_params[0].name:
62            param_name = iter_params[0].name[:iter_params[0].name.index('[')]
63    return (param_name, param_length)
64
65def createFixedChoiceComboBox(param, item_row):
66    """
67    Determines whether param is a fixed-choice parameter, modifies items in item_row appropriately and returns a combo
68    box containing the fixed choices. Returns None if param is not fixed-choice.
69   
70    item_row is a list of QStandardItem objects for insertion into the parameter table.
71    """
72
73    # Determine whether this is a fixed-choice parameter. There are lots of conditionals, simply because the
74    # implementation is not yet concrete; there are several possible indicators that the parameter is fixed-choice.
75    # TODO: (when the sasmodels implementation is concrete, clean this up)
76    choices = None
77    if isinstance(param.choices, (list, tuple)) and len(param.choices) > 0:
78        # The choices property is concrete in sasmodels, probably will use this
79        choices = param.choices
80    elif isinstance(param.units, (list, tuple)):
81        choices = [str(x) for x in param.units]
82
83    cbox = None
84    if choices is not None:
85        # Use combo box for input, if it is fixed-choice
86        cbox = AssociatedComboBox(item_row[1], idx_as_value=True)
87        cbox.addItems(choices)
88        item_row[2].setEditable(False)
89        item_row[3].setEditable(False)
90
91    return cbox
92
93def addParametersToModel(model, view, parameters, kernel_module, is2D):
94    """
95    Update local ModelModel with sasmodel parameters
96    """
97    multishell_parameters = getIterParams(parameters)
98    multishell_param_name, _ = getMultiplicity(parameters)
99
100    if is2D:
101        params = [p for p in parameters.kernel_parameters if p.type != 'magnetic']
102    else:
103        params = parameters.iq_parameters
104
105    for param in params:
106        # don't include shell parameters
107        if param.name == multishell_param_name:
108            continue
109
110        # Modify parameter name from <param>[n] to <param>1
111        item_name = param.name
112        if param in multishell_parameters:
113            continue
114
115        item1 = QtGui.QStandardItem(item_name)
116        item1.setCheckable(True)
117        item1.setEditable(False)
118
119        # check for polydisp params
120        if param.polydisperse:
121            poly_item = QtGui.QStandardItem("Polydispersity")
122            poly_item.setEditable(False)
123            item1_1 = QtGui.QStandardItem("Distribution")
124            item1_1.setEditable(False)
125
126            # Find param in volume_params
127            for p in parameters.form_volume_parameters:
128                if p.name != param.name:
129                    continue
130                width = kernel_module.getParam(p.name+'.width')
131                ptype = kernel_module.getParam(p.name+'.type')
132                item1_2 = QtGui.QStandardItem(str(width))
133                item1_2.setEditable(False)
134                item1_3 = QtGui.QStandardItem()
135                item1_3.setEditable(False)
136                item1_4 = QtGui.QStandardItem()
137                item1_4.setEditable(False)
138                item1_5 = QtGui.QStandardItem(ptype)
139                item1_5.setEditable(False)
140                poly_item.appendRow([item1_1, item1_2, item1_3, item1_4, item1_5])
141                break
142
143            # Add the polydisp item as a child
144            item1.appendRow([poly_item])
145
146        # Param values
147        item2 = QtGui.QStandardItem(str(param.default))
148        item3 = QtGui.QStandardItem(str(param.limits[0]))
149        item4 = QtGui.QStandardItem(str(param.limits[1]))
150        item5 = QtGui.QStandardItem(str(param.units))
151        item5.setEditable(False)
152
153        # Check if fixed-choice (returns combobox, if so, also makes some items uneditable)
154        row = [item1, item2, item3, item4, item5]
155        cbox = createFixedChoiceComboBox(param, row)
156
157        # Append to the model and use the combobox, if required
158        model.appendRow(row)
159        if cbox is not None:
160            view.setIndexWidget(item2.index(), cbox)
161
162def addSimpleParametersToModel(model, view, parameters, is2D):
163    """
164    Update local ModelModel with sasmodel parameters (non-dispersed, non-magnetic)
165    """
166    if is2D:
167        params = [p for p in parameters.kernel_parameters if p.type != 'magnetic']
168    else:
169        params = parameters.iq_parameters
170
171    for param in params:
172        # Create the top level, checkable item
173        item_name = param.name
174        item1 = QtGui.QStandardItem(item_name)
175        item1.setCheckable(True)
176        item1.setEditable(False)
177
178        # Param values
179        # TODO: add delegate for validation of cells
180        item2 = QtGui.QStandardItem(str(param.default))
181        item3 = QtGui.QStandardItem(str(param.limits[0]))
182        item4 = QtGui.QStandardItem(str(param.limits[1]))
183        item5 = QtGui.QStandardItem(str(param.units))
184        item5.setEditable(False)
185
186        # Check if fixed-choice (returns combobox, if so, also makes some items uneditable)
187        row = [item1, item2, item3, item4, item5]
188        cbox = createFixedChoiceComboBox(param, row)
189
190        # Append to the model and use the combobox, if required
191        model.appendRow(row)
192        if cbox is not None:
193            view.setIndexWidget(item2.index(), cbox)
194
195def markParameterDisabled(model, row):
196    """Given the QModel row number, format to show it is not available for fitting"""
197
198    # If an error column is present, there are a total of 6 columns.
199    items = [model.item(row, c) for c in range(6)]
200
201    model.blockSignals(True)
202
203    for item in items:
204        if item is None:
205            continue
206        item.setEditable(False)
207        item.setCheckable(False)
208
209    item = items[0]
210
211    font = QtGui.QFont()
212    font.setItalic(True)
213    item.setFont(font)
214    item.setForeground(QtGui.QBrush(QtGui.QColor(100, 100, 100)))
215    item.setToolTip("This parameter cannot be fitted.")
216
217    model.blockSignals(False)
218
219def addCheckedListToModel(model, param_list):
220    """
221    Add a QItem to model. Makes the QItem checkable
222    """
223    assert isinstance(model, QtGui.QStandardItemModel)
224    item_list = [QtGui.QStandardItem(item) for item in param_list]
225    item_list[0].setCheckable(True)
226    model.appendRow(item_list)
227
228def addHeadersToModel(model):
229    """
230    Adds predefined headers to the model
231    """
232    for i, item in enumerate(model_header_captions):
233        model.setHeaderData(i, QtCore.Qt.Horizontal, item)
234
235    model.header_tooltips = copy.copy(model_header_tooltips)
236
237def addErrorHeadersToModel(model):
238    """
239    Adds predefined headers to the model
240    """
241    model_header_error_captions = copy.copy(model_header_captions)
242    model_header_error_captions.insert(2, header_error_caption)
243    for i, item in enumerate(model_header_error_captions):
244        model.setHeaderData(i, QtCore.Qt.Horizontal, item)
245
246    model_header_error_tooltips = copy.copy(model_header_tooltips)
247    model_header_error_tooltips.insert(2, error_tooltip)
248    model.header_tooltips = copy.copy(model_header_error_tooltips)
249
250def addPolyHeadersToModel(model):
251    """
252    Adds predefined headers to the model
253    """
254    for i, item in enumerate(poly_header_captions):
255        model.setHeaderData(i, QtCore.Qt.Horizontal, item)
256
257    model.header_tooltips = copy.copy(poly_header_tooltips)
258
259
260def addErrorPolyHeadersToModel(model):
261    """
262    Adds predefined headers to the model
263    """
264    poly_header_error_captions = copy.copy(poly_header_captions)
265    poly_header_error_captions.insert(2, header_error_caption)
266    for i, item in enumerate(poly_header_error_captions):
267        model.setHeaderData(i, QtCore.Qt.Horizontal, item)
268
269    poly_header_error_tooltips = copy.copy(poly_header_tooltips)
270    poly_header_error_tooltips.insert(2, error_tooltip)
271    model.header_tooltips = copy.copy(poly_header_error_tooltips)
272
273def addShellsToModel(parameters, model, view, index):
274    """
275    Find out multishell parameters and update the model with the requested number of them
276    """
277    multishell_parameters = getIterParams(parameters)
278
279    for i in range(index):
280        for par in multishell_parameters:
281            # Create the name: <param>[<i>], e.g. "sld1" for parameter "sld[n]"
282            param_name = replaceShellName(par.name, i+1)
283            item1 = QtGui.QStandardItem(param_name)
284            item1.setCheckable(True)
285            # check for polydisp params
286            if par.polydisperse:
287                poly_item = QtGui.QStandardItem("Polydispersity")
288                item1_1 = QtGui.QStandardItem("Distribution")
289                # Find param in volume_params
290                for p in parameters.form_volume_parameters:
291                    if p.name != par.name:
292                        continue
293                    item1_2 = QtGui.QStandardItem(str(p.default))
294                    item1_3 = QtGui.QStandardItem(str(p.limits[0]))
295                    item1_4 = QtGui.QStandardItem(str(p.limits[1]))
296                    item1_5 = QtGui.QStandardItem(str(p.units))
297                    poly_item.appendRow([item1_1, item1_2, item1_3, item1_4, item1_5])
298                    break
299                item1.appendRow([poly_item])
300
301            item2 = QtGui.QStandardItem(str(par.default))
302            item3 = QtGui.QStandardItem(str(par.limits[0]))
303            item4 = QtGui.QStandardItem(str(par.limits[1]))
304            item5 = QtGui.QStandardItem(str(par.units))
305            item5.setEditable(False)
306
307            # Check if fixed-choice (returns combobox, if so, also makes some items uneditable)
308            row = [item1, item2, item3, item4, item5]
309            cbox = createFixedChoiceComboBox(par, row)
310
311            # Append to the model and use the combobox, if required
312            model.appendRow(row)
313            if cbox is not None:
314                view.setIndexWidget(item2.index(), cbox)
315
316def calculateChi2(reference_data, current_data):
317    """
318    Calculate Chi2 value between two sets of data
319    """
320    if reference_data is None or current_data is None:
321        return None
322    # WEIGHING INPUT
323    #from sas.sasgui.perspectives.fitting.utils import get_weight
324    #flag = self.get_weight_flag()
325    #weight = get_weight(data=self.data, is2d=self._is_2D(), flag=flag)
326    chisqr = None
327    if reference_data is None:
328        return chisqr
329
330    # temporary default values for index and weight
331    index = None
332    weight = None
333
334    # Get data: data I, theory I, and data dI in order
335    if isinstance(reference_data, Data2D):
336        if index is None:
337            index = numpy.ones(len(current_data.data), dtype=bool)
338        if weight is not None:
339            current_data.err_data = weight
340        # get rid of zero error points
341        index = index & (current_data.err_data != 0)
342        index = index & (numpy.isfinite(current_data.data))
343        fn = current_data.data[index]
344        gn = reference_data.data[index]
345        en = current_data.err_data[index]
346    else:
347        # 1 d theory from model_thread is only in the range of index
348        if index is None:
349            index = numpy.ones(len(current_data.y), dtype=bool)
350        if weight is not None:
351            current_data.dy = weight
352        if current_data.dy is None or current_data.dy == []:
353            dy = numpy.ones(len(current_data.y))
354        else:
355            ## Set consistently w/AbstractFitengine:
356            # But this should be corrected later.
357            dy = copy.deepcopy(current_data.dy)
358            dy[dy == 0] = 1
359        fn = current_data.y[index]
360        gn = reference_data.y
361        en = dy[index]
362    # Calculate the residual
363    try:
364        res = (fn - gn) / en
365    except ValueError:
366        #print "Chi2 calculations: Unmatched lengths %s, %s, %s" % (len(fn), len(gn), len(en))
367        return None
368
369    residuals = res[numpy.isfinite(res)]
370    chisqr = numpy.average(residuals * residuals)
371
372    return chisqr
373
374def residualsData1D(reference_data, current_data):
375    """
376    Calculate the residuals for difference of two Data1D sets
377    """
378    # temporary default values for index and weight
379    index = None
380    weight = None
381
382    # 1d theory from model_thread is only in the range of index
383    if current_data.dy is None or current_data.dy == []:
384        dy = numpy.ones(len(current_data.y))
385    else:
386        dy = weight if weight is not None else numpy.ones(len(current_data.y))
387        dy[dy == 0] = 1
388    fn = current_data.y[index][0]
389    gn = reference_data.y
390    en = dy[index][0]
391
392    # x values
393    x_current = current_data.x
394    x_reference = reference_data.x
395
396    # build residuals
397    residuals = Data1D()
398    if len(fn) == len(gn):
399        y = (fn - gn)/en
400        residuals.y = -y
401    elif len(fn) > len(gn):
402        residuals.y = (fn - gn[1:len(fn)])/en
403    else:
404        try:
405            y = numpy.zeros(len(current_data.y))
406            begin = 0
407            for i, x_value in enumerate(x_reference):
408                if x_value in x_current:
409                    begin = i
410                    break
411            end = len(x_reference)
412            endl = 0
413            for i, x_value in enumerate(list(x_reference)[::-1]):
414                if x_value in x_current:
415                    endl = i
416                    break
417            # make sure we have correct lengths
418            assert len(x_current) == len(x_reference[begin:end-endl])
419
420            y = (fn - gn[begin:end-endl])/en
421            residuals.y = y
422        except ValueError:
423            # value errors may show up every once in a while for malformed columns,
424            # just reuse what's there already
425            pass
426
427    residuals.x = current_data.x[index][0]
428    residuals.dy = numpy.ones(len(residuals.y))
429    residuals.dx = None
430    residuals.dxl = None
431    residuals.dxw = None
432    residuals.ytransform = 'y'
433    # For latter scale changes
434    residuals.xaxis('\\rm{Q} ', 'A^{-1}')
435    residuals.yaxis('\\rm{Residuals} ', 'normalized')
436
437    return residuals
438
439def residualsData2D(reference_data, current_data):
440    """
441    Calculate the residuals for difference of two Data2D sets
442    """
443    # temporary default values for index and weight
444    # index = None
445    weight = None
446
447    # build residuals
448    residuals = Data2D()
449    # Not for trunk the line below, instead use the line above
450    current_data.clone_without_data(len(current_data.data), residuals)
451    residuals.data = None
452    fn = current_data.data
453    gn = reference_data.data
454    en = current_data.err_data if weight is None else weight
455    residuals.data = (fn - gn) / en
456    residuals.qx_data = current_data.qx_data
457    residuals.qy_data = current_data.qy_data
458    residuals.q_data = current_data.q_data
459    residuals.err_data = numpy.ones(len(residuals.data))
460    residuals.xmin = min(residuals.qx_data)
461    residuals.xmax = max(residuals.qx_data)
462    residuals.ymin = min(residuals.qy_data)
463    residuals.ymax = max(residuals.qy_data)
464    residuals.q_data = current_data.q_data
465    residuals.mask = current_data.mask
466    residuals.scale = 'linear'
467    # check the lengths
468    if len(residuals.data) != len(residuals.q_data):
469        return None
470    return residuals
471
472def plotResiduals(reference_data, current_data):
473    """
474    Create Data1D/Data2D with residuals, ready for plotting
475    """
476    data_copy = copy.deepcopy(current_data)
477    # Get data: data I, theory I, and data dI in order
478    method_name = current_data.__class__.__name__
479    residuals_dict = {"Data1D": residualsData1D,
480                      "Data2D": residualsData2D}
481
482    residuals = residuals_dict[method_name](reference_data, data_copy)
483
484    theory_name = str(current_data.name.split()[0])
485    res_name = reference_data.filename if reference_data.filename else reference_data.name
486    residuals.name = "Residuals for " + str(theory_name) + "[" + res_name + "]"
487    residuals.title = residuals.name
488    residuals.ytransform = 'y'
489
490    # when 2 data have the same id override the 1 st plotted
491    # include the last part if keeping charts for separate models is required
492    residuals.id = "res" + str(reference_data.id) # + str(theory_name)
493    # group_id specify on which panel to plot this data
494    group_id = reference_data.group_id
495    residuals.group_id = "res" + str(group_id)
496
497    # Symbol
498    residuals.symbol = 0
499    residuals.hide_error = False
500
501    return residuals
502
503def binary_encode(i, digits):
504    return [i >> d & 1 for d in range(digits)]
505
506def getWeight(data, is2d, flag=None):
507    """
508    Received flag and compute error on data.
509    :param flag: flag to transform error of data.
510    """
511    weight = None
512    if data is None:
513        return []
514    if is2d:
515        if not hasattr(data, 'err_data'):
516            return []
517        dy_data = data.err_data
518        data = data.data
519    else:
520        if not hasattr(data, 'dy'):
521            return []
522        dy_data = data.dy
523        data = data.y
524
525    if flag == 0:
526        weight = numpy.ones_like(data)
527    elif flag == 1:
528        weight = dy_data
529    elif flag == 2:
530        weight = numpy.sqrt(numpy.abs(data))
531    elif flag == 3:
532        weight = numpy.abs(data)
533    return weight
534
535def updateKernelWithResults(kernel, results):
536    """
537    Takes model kernel and applies results dict to its parameters,
538    returning the modified (deep) copy of the kernel.
539    """
540    assert isinstance(results, dict)
541    local_kernel = copy.deepcopy(kernel)
542
543    for parameter in results.keys():
544        # Update the parameter value - note: this supports +/-inf as well
545        local_kernel.setParam(parameter, results[parameter][0])
546
547    return local_kernel
548
549
550def getStandardParam(model=None):
551    """
552    Returns a list with standard parameters for the current model
553    """
554    param = []
555    num_rows = model.rowCount()
556    if num_rows < 1:
557        return None
558
559    for row in range(num_rows):
560        param_name = model.item(row, 0).text()
561        checkbox_state = model.item(row, 0).checkState() == QtCore.Qt.Checked
562        value = model.item(row, 1).text()
563        column_shift = 0
564        if model.columnCount() == 5: # no error column
565            error_state = False
566            error_value = 0.0
567        else:
568            error_state = True
569            error_value = model.item(row, 2).text()
570            column_shift = 1
571        min_state = True
572        max_state = True
573        min_value = model.item(row, 2+column_shift).text()
574        max_value = model.item(row, 3+column_shift).text()
575        unit = ""
576        if model.item(row, 4+column_shift) is not None:
577            unit = model.item(row, 4+column_shift).text()
578
579        param.append([checkbox_state, param_name, value, "",
580                        [error_state, error_value],
581                        [min_state, min_value],
582                        [max_state, max_value], unit])
583
584    return param
585
586def getOrientationParam(kernel_module=None):
587    """
588    Get the dictionary with orientation parameters
589    """
590    param = []
591    if kernel_module is None:
592        return None
593    for param_name in list(kernel_module.params.keys()):
594        name = param_name
595        value = kernel_module.params[param_name]
596        min_state = True
597        max_state = True
598        error_state = False
599        error_value = 0.0
600        checkbox_state = True #??
601        details = kernel_module.details[param_name] #[unit, mix, max]
602        param.append([checkbox_state, name, value, "",
603                     [error_state, error_value],
604                     [min_state, details[1]],
605                     [max_state, details[2]], details[0]])
606
607    return param
608
609def formatParameters(parameters):
610    """
611    Prepare the parameter string in the standard SasView layout
612    """
613    assert parameters is not None
614    assert isinstance(parameters, list)
615    output_string = "sasview_parameter_values:"
616    for parameter in parameters:
617        output_string += ",".join([p for p in parameter if p is not None])
618        output_string += ":"
619    return output_string
620
621def formatParametersExcel(parameters):
622    """
623    Prepare the parameter string in the Excel format (tab delimited)
624    """
625    assert parameters is not None
626    assert isinstance(parameters, list)
627    crlf = chr(13) + chr(10)
628    tab = chr(9)
629
630    output_string = ""
631    # names
632    names = ""
633    values = ""
634    for parameter in parameters:
635        names += parameter[0]+tab
636        # Add the error column if fitted
637        if parameter[1] == "True" and parameter[3] is not None:
638            names += parameter[0]+"_err"+tab
639
640        values += parameter[2]+tab
641        if parameter[1] == "True" and parameter[3] is not None:
642            values += parameter[3]+tab
643        # add .npts and .nsigmas when necessary
644        if parameter[0][-6:] == ".width":
645            names += parameter[0].replace('.width', '.nsigmas') + tab
646            names += parameter[0].replace('.width', '.npts') + tab
647            values += parameter[5] + tab + parameter[4] + tab
648
649    output_string = names + crlf + values
650    return output_string
651
652def formatParametersLatex(parameters):
653    """
654    Prepare the parameter string in latex
655    """
656    assert parameters is not None
657    assert isinstance(parameters, list)
658    output_string = r'\begin{table}'
659    output_string += r'\begin{tabular}[h]'
660
661    crlf = chr(13) + chr(10)
662    output_string += '{|'
663    output_string += 'l|l|'*len(parameters)
664    output_string += r'}\hline'
665    output_string += crlf
666
667    for index, parameter in enumerate(parameters):
668        name = parameter[0] # Parameter name
669        output_string += name.replace('_', r'\_')  # Escape underscores
670        # Add the error column if fitted
671        if parameter[1] == "True" and parameter[3] is not None:
672            output_string += ' & '
673            output_string += parameter[0]+r'\_err'
674
675        if index < len(parameters) - 1:
676            output_string += ' & '
677
678        # add .npts and .nsigmas when necessary
679        if parameter[0][-6:] == ".width":
680            output_string += parameter[0].replace('.width', '.nsigmas') + ' & '
681            output_string += parameter[0].replace('.width', '.npts')
682
683            if index < len(parameters) - 1:
684                output_string += ' & '
685
686    output_string += r'\\ \hline'
687    output_string += crlf
688
689    # Construct row of values and errors
690    for index, parameter in enumerate(parameters):
691        output_string += parameter[2]
692        if parameter[1] == "True" and parameter[3] is not None:
693            output_string += ' & '
694            output_string += parameter[3]
695
696        if index < len(parameters) - 1:
697            output_string += ' & '
698
699        # add .npts and .nsigmas when necessary
700        if parameter[0][-6:] == ".width":
701            output_string += parameter[5] + ' & '
702            output_string += parameter[4]
703
704            if index < len(parameters) - 1:
705                output_string += ' & '
706
707    output_string += r'\\ \hline'
708    output_string += crlf
709    output_string += r'\end{tabular}'
710    output_string += r'\end{table}'
711
712    return output_string
Note: See TracBrowser for help on using the repository browser.