OP

Interface to OptiPass.exe (the command line version of OptiPass)

An instance of the OP class encapsulates all the information related to a single optimization run. The constructor, called from the GUI, is passed the options selected by the user (budget levels, restoration targets, etc). Methods of the class set up and run an optimization based on these options:

An OP object can also be instantiated by the command line API in main.py. When run on macOS or Linux it can be used to test the functions that creates the OP input file and parse the results.
When run on a Windows system it can also run OptiPass.

Parameters:
  • project (Project) –

    a Project object containing barrier file

  • regions (list[str]) –

    a list of region names from the barrier file

  • targets (list[str]) –

    a list of 2-letter target IDs

  • weights (list[str]) –

    optional list of integer weights for each target

  • climate (str) –

    either 'Current' or 'Future'

Source code in src/tidegates/optipass.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(self, project: Project, regions: list[str], targets: list[str], weights: list[str], climate: str):
    '''
    Instantiate a new OP object.

    Arguments:
      project: a Project object containing barrier file
      regions: a list of region names from the barrier file
      targets: a list of 2-letter target IDs
      weights: optional list of integer weights for each target
      climate: either 'Current' or 'Future'
    '''
    self.project = project
    self.regions = regions
    if weights:
        self.weights = [int(s) for s in weights]
        self.weighted = True
    else:
        self.weights = [1] * len(targets)
        self.weighted = False
    self.climate = climate
    structs = self.project.targets[climate] if climate else self.project.targets
    self.targets = [structs[t] for t in targets]
    self.input_frame = None
    self.outputs = None

generate_input_frame()

Create a data frame that will be written in the format of a "barrier file" that will be read by OptiPass. Save the frame as the input_frame attribute of the object.

src/tidegates/optipass.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def generate_input_frame(self):
    '''
    Create a data frame that will be written in the format of a "barrier
    file" that will be read by OptiPass.  Save the frame as the
    input_frame attribute of the object.
    '''

    filtered = self.project.data[self.project.data.REGION.isin(self.regions)]
    filtered.index = list(range(len(filtered)))

    df = filtered[['BARID','REGION']]
    header = ['ID','REG']

    df = pd.concat([df, pd.Series(np.ones(len(filtered)), name='FOCUS', dtype=int)], axis=1)
    header.append('FOCUS')

    df = pd.concat([df, filtered['DSID']], axis=1)
    header.append('DSID')

    for t in self.targets:
        df = pd.concat([df, filtered[t.habitat]], axis=1)
        header.append('HAB_'+t.abbrev)

    for t in self.targets:
        df = pd.concat([df, filtered[t.prepass]], axis=1)
        header.append('PRE_'+t.abbrev)

    df = pd.concat([df, filtered['NPROJ']], axis=1)
    header.append('NPROJ')

    df = pd.concat([df, pd.Series(np.zeros(len(filtered)), name='ACTION', dtype=int)], axis=1)
    header.append('ACTION')

    df = pd.concat([df, filtered['COST']], axis=1)
    header += ['COST']

    for t in self.targets:
        df = pd.concat([df, filtered[t.postpass]], axis=1)
        header.append('POST_'+t.abbrev)

    df.columns = header
    self.input_frame = df

    return df

run(budgets, preview)

Generate and execute the shell commands that run OptiPass. If the shell environment includes a variable named WINEARCH it means the script is running on Linux, and we need to use Wine, otherwise build a command that will run on Windows.

The first time OptiPass is run it will be given a budget of $0 to establish the current passage levels. It's then run once more at each level in the budgets list.

Each time OptiPass is run it is passed the same input file, but it will write outputs to a separate file that includes the budget level in the file name. The list of output file names is saved in an instance variable.

Parameters:
  • budgets (list[int]) –

    a list of budget values (dollar amounts)

  • preview (bool) –

    if True, print shell commands but don't execute them

src/tidegates/optipass.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
def run(self, budgets: list[int], preview: bool):
    '''
    Generate and execute the shell commands that run OptiPass.  If the shell
    environment includes a variable named WINEARCH it means the script is
    running on Linux, and we need to use Wine, otherwise build a command that
    will run on Windows.

    The first time OptiPass is run it will be given a budget of $0 to establish
    the current passage levels.  It's then run once more at each level in the
    budgets list.

    Each time OptiPass is run it is passed the same input file, but it will
    write outputs to a separate file that includes the budget level in the file name.
    The list of output file names is saved in an instance variable.

    Arguments:
      budgets:  a list of budget values (dollar amounts)
      preview:  if True, print shell commands but don't execute them
    '''
    if platform.system() == 'Windows':
        app = 'bin\\OptiPassMain.exe'
    elif platform.system() == 'Linux' and os.environ.get('WINEARCH'):
        app = 'wine bin/OptiPassMain.exe'
    else:
        Logging.log(f'{platform.system()} not configured to run WINE')
        self.outputs = None
        return

    template = app + ' -f {bf} -o {of} -b {n}'

    df = self.generate_input_frame()
    _, barrier_file = tempfile.mkstemp(suffix='.txt', dir='./tmp', text=True)
    df.to_csv(barrier_file, index=False, sep='\t', lineterminator=os.linesep, na_rep='NA')

    self.budget_max, self.budget_delta = budgets
    num_budgets = self.budget_max // self.budget_delta
    outputs = []
    root, _ = os.path.splitext(barrier_file)
    for i in range(num_budgets + 1):
        outfile = f'{root}_{i+1}.txt'
        budget = self.budget_delta * i
        cmnd = template.format(bf=barrier_file, of=outfile, n=budget)
        if (num_targets := len(self.targets)) > 1:
            cmnd += ' -t {}'.format(num_targets)
            cmnd += ' -w ' + ', '.join([str(n) for n in self.weights])
        Logging.log(cmnd)
        print(cmnd)
        if not preview:
            res = subprocess.run(cmnd, shell=True, capture_output=True)
            print(res.stdout)
            print(res.stderr)
        if preview or (res.returncode == 0):
            outputs.append(outfile)
            # progress_hook()
        else:
            Logging.log('OptiPass failed:')
            Logging.log(res.stderr)
    self.outputs = outputs

collect_results(scaled=False)

Parse the output files produced by OptiPass (the file names are in self.outputs) and collect the results, which are saved in two Pandas data frames.

src/tidegates/optipass.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def collect_results(self, scaled=False):
    '''
    Parse the output files produced by OptiPass (the file names are in
    self.outputs) and collect the results, which are saved in two Pandas
    data frames.
    '''
    df = self.input_frame
    G = nx.from_pandas_edgelist(
        df[df.DSID.notnull()], 
        source='ID', 
        target='DSID', 
        create_using=nx.DiGraph
    )
    for x in df[df.DSID.isnull()].ID:
        G.add_node(x)
    self.paths = { n: self._path_from(n,G) for n in G.nodes }

    cols = { x: [] for x in ['budget', 'habitat', 'gates']}
    for fn in self.outputs:
        self._parse_op_output(fn, cols)
    self.summary = pd.DataFrame(cols)

    dct = {}
    for i in range(len(self.summary)):
        b = int(self.summary.budget[i])
        dct[b] = [ 1 if g in self.summary.gates[i] else 0 for g in self.input_frame.ID]
    self.matrix = pd.DataFrame(dct, index=self.input_frame.ID)
    self.matrix['count'] = self.matrix.sum(axis=1)
    self.potential_habitat(self.targets, scaled)

_path_from(x, graph)

Return a list of nodes in the path from a barrier to a downstream barrier that has no descendants.

Parameters:
  • x

    the barrier at the start of the path

  • graph

    the digraph with barrier connectivity

src/tidegates/optipass.py
200
201
202
203
204
205
206
207
208
209
def _path_from(self, x, graph):
    '''
    Return a list of nodes in the path from a barrier to a downstream barrier that
    has no descendants.

    Arguments:
      x: the barrier at the start of the path
      graph:  the digraph with barrier connectivity
    '''
    return [x] + [child for _, child in nx.dfs_edges(graph,x)]

_parse_op_output(fn, dct)

Parse an output file, appending results to the lists. We need to handle two different formats, depending on whether there was one target or more than one.

Parameters:
  • fn

    the name of the file to parse

  • dct

    a dictionary of column names, results are appended to lists in this dictionary

src/tidegates/optipass.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
def _parse_op_output(self, fn, dct):
    '''
    Parse an output file, appending results to the lists.  We need to handle
    two different formats, depending on whether there was one target or more
    than one.

    Arguments:
      fn:  the name of the file to parse
      dct:  a dictionary of column names, results are appended to lists in this dictionary
    '''

    def parse_header_line(line, tag):
        tokens = line.strip().split()
        if not tokens[0].startswith(tag):
            return None
        return tokens[1]

    with open(fn) as f:
        amount = parse_header_line(f.readline(), 'BUDGET')
        dct['budget'].append(float(amount))
        if parse_header_line(f.readline(), 'STATUS') == 'NO_SOLN':
            raise RuntimeError('No solution')
        f.readline()                        # skip OPTGAP
        line = f.readline()
        if line.startswith('PTNL'):
            # dct['weights'].append([1.0])
            hab = parse_header_line(line, 'PTNL_HABITAT')
            dct['habitat'].append(float(hab))
            f.readline()                    # skip NETGAIN
        else:
            lst = []
            while w := parse_header_line(f.readline(), 'TARGET'):
                lst.append(float(w))
            # dct['weights'].append(lst)
            while line := f.readline():      # skip the individual habitat lines
                if line.startswith('WT_PTNL_HAB'):
                    break
            hab = parse_header_line(line, 'WT_PTNL_HAB')
            dct['habitat'].append(float(hab))
            f.readline()                    # skip WT_NETGAIN
        f.readline()                        # skip blank line
        f.readline()                        # skip header
        lst = []
        while line := f.readline():
            name, action = line.strip().split()
            if action == '1':
                lst.append(name)
        dct['gates'].append(lst)

potential_habitat(tlist, scaled)

Compute the potential habitat available before and after restoration, using the original unscaled habitat values.

Parameters:
  • tlist

    list of target IDs

  • scaled

    True if we should create weighted potential habitat values

src/tidegates/optipass.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
def potential_habitat(self, tlist, scaled):
    '''
    Compute the potential habitat available before and after restoration, using
    the original unscaled habitat values.

    Arguments:
      tlist:  list of target IDs
      scaled:  True if we should create weighted potential habitat values
    '''
    filtered = self.project.data[self.project.data.REGION.isin(self.regions)].fillna(0)
    filtered.index = filtered.BARID
    wph = np.zeros(len(self.summary))
    for i in range(len(tlist)):
        t = self.targets[i]
        cp = self._ah(t, filtered, scaled)
        wph += (self.weights[i] * cp)
        col = pd.DataFrame({t.abbrev: cp})
        self.summary = pd.concat([self.summary, col], axis=1)
        if not scaled:
            gain = self._gain(t, filtered)
            self.matrix = pd.concat([self.matrix, filtered[t.unscaled], gain], axis=1)

    # If scaled is True add the wph column so we can compare with OP values
    if scaled:
        self.summary = pd.concat([self.summary, pd.DataFrame({'wph': wph})], axis = 1)
    self.summary['netgain'] = self.summary.habitat - self.summary.habitat[0]
    return self.summary

_ah(target, data, scaled)

Compute the available habitat for a target, in the form of a vector of habitat values for each budget level;

Parameters:
  • target

    a Target object (with ID and names of data columns to use)

  • data

    the barrier dataframe

  • scaled

    if True is the scaled benefit column

src/tidegates/optipass.py
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
def _ah(self, target, data, scaled):
    """
    Compute the available habitat for a target, in the form of
    a vector of habitat values for each budget level;

    Arguments:
      target:  a Target object (with ID and names of data columns to use)
      data:  the barrier dataframe
      scaled:  if True is the scaled benefit column
    """
    budgets = self.summary.budget
    m = self.matrix
    res = np.zeros(len(budgets))
    for i in range(len(res)):
        action = m.iloc[:,i]
        pvec = data[target.postpass].where(action == 1, data[target.prepass])
        habitat = data[target.habitat if scaled else target.unscaled]
        res[i] = sum(prod(pvec[x] for x in self.paths[b]) * habitat[b] for b in m.index)
    return res

table_view(test=False)

Create a table that will be displayed by the GUI

src/tidegates/optipass.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
def table_view(self, test=False):
    '''
    Create a table that will be displayed by the GUI
    '''
    filtered = self.project.data[self.project.data.REGION.isin(self.regions)]
    filtered = filtered.set_index('BARID')

    if test:
        info_cols = other_cols = { }
    else:
        info_cols = {
            'REGION': 'Region',
            'BarrierType': 'Type',
            'DSID': 'DSID',
            'COST': 'Cost',
        }

        other_cols = {
            'PrimaryTG': 'Primary',
            'DominantTG': 'Dominant',
            'POINT_X': 'Longitude',
            'POINT_Y': 'Latitude',
        }

    budget_cols = OP.format_budgets([c for c in self.matrix.columns if isinstance(c,int) and c > 0])

    df = pd.concat([
        filtered[info_cols.keys()].rename(columns=info_cols),
        self.matrix.rename(columns=budget_cols),
        filtered[other_cols.keys()].rename(columns=other_cols),
    ], axis=1)

    dct = { t.unscaled: t.short+'_hab' for t in self.targets }
    dct |= { f'GAIN_{t.abbrev}': t.short+'_gain' for t in self.targets }
    df = df.rename(columns=dct)

    del df[0]
    df = df[df['count'] > 0].sort_values(by='count', ascending=False).fillna('-')
    df = df.rename(columns={'count': 'Count'})
    df = df.reset_index(names=['ID'])

    # df.columns = pd.MultiIndex.from_tuples(df.columns)
    return df

make_roi_curves()

Generate ROI plots based on computed benefits.

src/tidegates/optipass.py
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
def make_roi_curves(self):
    """
    Generate ROI plots based on computed benefits.
    """
    figures = []
    download_figures = []

    climate = None

    subtitle = 'Region: ' if len(self.regions) == 1 else 'Regions: '
    subtitle +=  ', '.join(self.regions)

    for i, t in enumerate(self.targets):
        title = t.long
        if t.infra:
            climate = self.climate
            title += f' ({climate} Climate)'
        if self.weighted:
            title += f' ⨉ {int(self.weights[i])}'
        f = self.bokeh_figure(self.summary.budget, self.summary[t.abbrev], title, subtitle, t.label)
        figures.append((t.short, f))
        f = self.pyplot_figure(self.summary.budget, self.summary[t.abbrev], title, subtitle, t.label)
        download_figures.append((t.short, f))

    if len(self.targets) > 1:
        title = 'Combined Potential Benefit'
        if climate:
            title += f' ({climate} Climate)'
        f = self.bokeh_figure(self.summary.budget, self.summary.netgain, title, subtitle, 'Weighted Net Gain')
        figures.insert(0, ('Net', f))
        f = self.pyplot_figure(self.summary.budget, self.summary[t.abbrev], title, subtitle, 'Weighted Net Gain')
        download_figures.insert(0, ('Net', f))

    self.display_figures = figures
    self.download_figures = download_figures