• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pypest / pyemu / 5887625428

17 Aug 2023 06:23AM UTC coverage: 79.857% (+1.5%) from 78.319%
5887625428

push

github

briochh
Merge branch 'develop'

11386 of 14258 relevant lines covered (79.86%)

6.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.28
/pyemu/pst/pst_handler.py
1
from __future__ import print_function, division
9✔
2
import os
9✔
3
import glob
9✔
4
import re
9✔
5
import copy
9✔
6
import shutil
9✔
7
import time
9✔
8
import warnings
9✔
9
import numpy as np
9✔
10
from numpy.lib.type_check import real_if_close
9✔
11
import pandas as pd
9✔
12

13

14
pd.options.display.max_colwidth = 100
9✔
15
pd.options.mode.use_inf_as_na = True
9✔
16
import pyemu
9✔
17
from ..pyemu_warnings import PyemuWarning
9✔
18
from pyemu.pst.pst_controldata import ControlData, SvdData, RegData
9✔
19
from pyemu.pst import pst_utils
9✔
20
from pyemu.plot import plot_utils
9✔
21

22
# from pyemu.utils.os_utils import run
23

24

25
class Pst(object):
9✔
26
    """All things PEST(++) control file
9✔
27

28
    Args:
29
        filename (`str`):  the name of the control file
30
        load (`bool`, optional): flag to load the control file. Default is True
31
        resfile (`str`, optional): corresponding residual file.  If `None`, a residual file
32
            with the control file base name is sought.  Default is `None`
33

34
    Note:
35
        This class is the primary mechanism for dealing with PEST control files.  Support is provided
36
        for constructing new control files as well as manipulating existing control files.
37

38
    Example::
39

40
        pst = pyemu.Pst("my.pst")
41
        pst.control_data.noptmax = -1
42
        pst.write("my_new.pst")
43

44
    """
45

46
    def __init__(self, filename, load=True, resfile=None):
9✔
47

48
        self.parameter_data = None
9✔
49
        """pandas.DataFrame:  '* parameter data' information.  Columns are 
4✔
50
        standard PEST variable names
51
        
52
        Example::
53
            
54
            pst.parameter_data.loc[:,"partrans"] = "log"
55
            pst.parameter_data.loc[:,"parubnd"] = 10.0
56
        
57
        """
58
        self.observation_data = None
9✔
59
        """pandas.DataFrame:  '* observation data' information.  Columns are standard PEST
4✔
60
        variable names
61
        
62
        Example::
63
        
64
            pst.observation_data.loc[:,"weight"] = 1.0
65
            pst.observation_data.loc[:,"obgnme"] = "obs_group"
66
        
67
        """
68
        self.prior_information = None
9✔
69
        """pandas.DataFrame:  '* prior information' data.  Columns are standard PEST
4✔
70
        variable names"""
71

72
        self.model_input_data = pst_utils.pst_config["null_model_io"].copy()
9✔
73
        self.model_output_data = pst_utils.pst_config["null_model_io"].copy()
9✔
74

75
        self.filename = filename
9✔
76
        self.resfile = resfile
9✔
77
        self.__res = None
9✔
78
        self.__pi_count = 0
9✔
79
        self.with_comments = False
9✔
80
        self.comments = {}
9✔
81
        self.other_sections = {}
9✔
82
        self.new_filename = None
9✔
83
        for key, value in pst_utils.pst_config.items():
9✔
84
            self.__setattr__(key, copy.copy(value))
9✔
85
        # self.tied = None
86
        self.control_data = ControlData()
9✔
87
        """pyemu.pst.pst_controldata.ControlData:  '* control data' information.  
4✔
88
        Access with standard PEST variable names 
89
        
90
        Example:: 
91
            
92
            pst.control_data.noptmax = 2
93
            pst.control_data.pestmode = "estimation"
94
            
95
            
96
        """
97
        self.svd_data = SvdData()
9✔
98
        """pyemu.pst.pst_controldata.SvdData: '* singular value decomposition' section information.  
4✔
99
        Access with standard PEST variable names
100
        
101
        Example::
102
        
103
            pst.svd_data.maxsing = 100
104
            
105
        
106
        """
107
        self.reg_data = RegData()
9✔
108
        """pyemu.pst.pst_controldata.RegData: '* regularization' section information.
4✔
109
        Access with standard PEST variable names.
110
        
111
        Example:: 
112
        
113
            pst.reg_data.phimlim = 1.00 #yeah right!
114

115
        
116
        """
117
        if load:
9✔
118
            if not os.path.exists(filename):
9✔
119
                raise Exception("pst file not found:{0}".format(filename))
×
120

121
            self.load(filename)
9✔
122

123
    def __setattr__(self, key, value):
9✔
124
        if key == "model_command":
9✔
125
            if isinstance(value, str):
9✔
126
                value = [value]
9✔
127
        super(Pst, self).__setattr__(key, value)
9✔
128

129
    @classmethod
9✔
130
    def from_par_obs_names(cls, par_names=["par1"], obs_names=["obs1"]):
9✔
131
        """construct a shell `Pst` instance from parameter and observation names
132

133
        Args:
134
            par_names ([`str`]): list of parameter names.  Default is [`par1`]
135
            obs_names ([`str`]): list of observation names.  Default is [`obs1`]
136

137
        Note:
138
            While this method works, it does not make template or instruction files.
139
            Users are encouraged to use `Pst.from_io_files()` for more usefulness
140

141
        Example::
142

143
            par_names = ["par1","par2"]
144
            obs_names = ["obs1","obs2"]
145
            pst = pyemu.Pst.from_par_obs_names(par_names,obs_names)
146

147
        """
148
        return pst_utils.generic_pst(par_names=par_names, obs_names=obs_names)
8✔
149

150
    @staticmethod
9✔
151
    def get_constraint_tags(ltgt='lt'):
9✔
152
        if ltgt == 'lt':
9✔
153
            return "l_", "less", ">@"
9✔
154
        else:
155
            return "g_", "greater", "<@"
9✔
156

157
    @property
9✔
158
    def phi(self):
9✔
159
        """get the weighted total objective function.
160

161
        Returns:
162
            `float`: sum of squared residuals
163

164
        Note:
165
            Requires `Pst.res` (the residuals file) to be available
166

167
        """
168
        psum = 0.0
9✔
169
        for _, contrib in self.phi_components.items():
9✔
170
            psum += contrib
9✔
171
        return psum
9✔
172

173
    @property
9✔
174
    def phi_components(self):
9✔
175
        """get the individual components of the total objective function
176

177
        Returns:
178
            `dict`: dictionary of observation group, contribution to total phi
179

180

181
        Note:
182
            Requires `Pst.res` (the residuals file) to be available
183

184
        """
185

186
        # calculate phi components for each obs group
187
        components = {}
9✔
188
        ogroups = self.observation_data.groupby("obgnme").groups
9✔
189
        rgroups = self.res.groupby("group").groups
9✔
190
        self.res.index = self.res.name
9✔
191
        for og, onames in ogroups.items():
9✔
192
            # assert og in rgroups.keys(),"Pst.phi_componentw obs group " +\
193
            #    "not found: " + str(og)
194
            # og_res_df = self.res.ix[rgroups[og]]
195
            og_res_df = self.res.loc[onames, :].dropna(axis=1)
9✔
196
            # og_res_df.index = og_res_df.name
197
            og_df = self.observation_data.loc[ogroups[og], :]
9✔
198
            og_df.index = og_df.obsnme
9✔
199
            # og_res_df = og_res_df.loc[og_df.index,:]
200
            assert og_df.shape[0] == og_res_df.shape[0], (
9✔
201
                " Pst.phi_components error: group residual dataframe row length"
202
                + "doesn't match observation data group dataframe row length"
203
                + str(og_df.shape)
204
                + " vs. "
205
                + str(og_res_df.shape)
206
                + ","
207
                + og
208
            )
209
            if "modelled" not in og_res_df.columns:
9✔
210
                print(og_res_df)
×
211
                m = self.res.loc[onames, "modelled"]
×
212
                print(m.loc[m.isna()])
×
213
                raise Exception("'modelled' not in res df columns for group " + og)
×
214
            # components[og] = np.sum((og_res_df["residual"] *
215
            #                          og_df["weight"]) ** 2)
216
            mod_vals = og_res_df.loc[og_df.obsnme, "modelled"]
9✔
217
            if og.lower().startswith(self.get_constraint_tags('gt')):
9✔
218
                mod_vals.loc[mod_vals >= og_df.loc[:, "obsval"]] = og_df.loc[
8✔
219
                    :, "obsval"
220
                ]
221
            elif og.lower().startswith(self.get_constraint_tags('lt')):
9✔
222
                mod_vals.loc[mod_vals <= og_df.loc[:, "obsval"]] = og_df.loc[
8✔
223
                    :, "obsval"
224
                ]
225
            components[og] = np.sum(
9✔
226
                ((og_df.loc[:, "obsval"] - mod_vals) * og_df.loc[:, "weight"]) ** 2
227
            )
228
        if (
9✔
229
            not self.control_data.pestmode.startswith("reg")
230
            and self.prior_information.shape[0] > 0
231
        ):
232
            ogroups = self.prior_information.groupby("obgnme").groups
9✔
233
            for og in ogroups.keys():
9✔
234
                if og not in rgroups.keys():
9✔
235
                    raise Exception(
×
236
                        "Pst.adjust_weights_res() obs group " + "not found: " + str(og)
237
                    )
238
                og_res_df = self.res.loc[rgroups[og], :]
9✔
239
                og_res_df.index = og_res_df.name
9✔
240
                og_df = self.prior_information.loc[ogroups[og], :]
9✔
241
                og_df.index = og_df.pilbl
9✔
242
                og_res_df = og_res_df.loc[og_df.index, :].copy()
9✔
243
                if og_df.shape[0] != og_res_df.shape[0]:
9✔
244
                    raise Exception(
×
245
                        " Pst.phi_components error: group residual dataframe row length"
246
                        + "doesn't match observation data group dataframe row length"
247
                        + str(og_df.shape)
248
                        + " vs. "
249
                        + str(og_res_df.shape)
250
                    )
251
                if og.lower().startswith(self.get_constraint_tags('gt')):
9✔
252
                    gidx = og_res_df.loc[:, "residual"] >= 0
×
253
                    og_res_df.loc[gidx, "residual"] = 0
×
254
                elif og.lower().startswith(self.get_constraint_tags('lt')):
9✔
255
                    lidx = og_res_df.loc[:, "residual"] <= 0
×
256
                    og_res_df.loc[lidx, "residual"] = 0
×
257
                components[og] = np.sum((og_res_df["residual"] * og_df["weight"]) ** 2)
9✔
258

259
        return components
9✔
260

261
    @property
9✔
262
    def phi_components_normalized(self):
9✔
263
        """get the individual components of the total objective function
264
            normalized to the total PHI being 1.0
265

266
        Returns:
267
            `dict`:  dictionary of observation group,
268
            normalized contribution to total phi
269

270
        Note:
271
            Requires `Pst.res` (the residuals file) to be available
272

273

274
        """
275
        # use a dictionary comprehension to go through and normalize each component of phi to the total
276
        phi = self.phi
9✔
277
        comps = self.phi_components
9✔
278
        norm = {i: c / phi for i, c in comps.items()}
9✔
279
        #print(phi, comps, norm)
280

281
        return norm
9✔
282

283
    def set_res(self, res):
9✔
284
        """reset the private `Pst.res` attribute.
285

286
        Args:
287
            res : (`pandas.DataFrame` or `str`): something to use as Pst.res attribute.
288
                If `res` is `str`, a dataframe is read from file `res`
289

290

291
        """
292
        if isinstance(res, str):
9✔
293
            res = pst_utils.read_resfile(res)
9✔
294
        self.__res = res
9✔
295

296
    @property
9✔
297
    def res(self):
9✔
298
        """get the residuals dataframe attribute
299

300
        Returns:
301
            `pandas.DataFrame`: a dataframe containing the
302
            residuals information.
303

304
        Note:
305
            if the Pst.__res attribute has not been loaded,
306
            this call loads the res dataframe from a file
307

308
        Example::
309

310
            # print the observed and simulated values for non-zero weighted obs
311
            print(pst.res.loc[pst.nnz_obs_names,["modelled","measured"]])
312

313
        """
314
        if self.__res is not None:
9✔
315
            return self.__res
9✔
316
        else:
317
            if self.resfile is not None:
9✔
318
                if not os.path.exists(self.resfile):
8✔
319
                    raise Exception(
×
320
                        "Pst.res: self.resfile " + str(self.resfile) + " does not exist"
321
                    )
322
            else:
323
                filename = str(self.filename)
9✔
324
                self.resfile = filename.replace(".pst", ".res")
9✔
325
                if not os.path.exists(self.resfile):
9✔
326
                    self.resfile = self.resfile.replace(".res", ".rei")
9✔
327
                    if not os.path.exists(self.resfile):
9✔
328
                        self.resfile = self.resfile.replace(".rei", ".base.rei")
8✔
329
                        if not os.path.exists(self.resfile):
8✔
330
                            if self.new_filename is not None:
8✔
331
                                self.resfile = self.new_filename.replace(".pst", ".res")
×
332
                                if not os.path.exists(self.resfile):
×
333
                                    self.resfile = self.resfile.replace(".res", ".rei")
×
334
                                    if not os.path.exists(self.resfile):
×
335
                                        raise Exception(
×
336
                                            "Pst.res: "
337
                                            + "could not residual file case.res"
338
                                            + " or case.rei"
339
                                            + " or case.base.rei"
340
                                            + " or case.obs.csv"
341
                                        )
342

343
            res = pst_utils.read_resfile(self.resfile)
9✔
344
            missing_bool = self.observation_data.obsnme.apply(
9✔
345
                lambda x: x not in res.name
346
            )
347
            missing = self.observation_data.obsnme[missing_bool]
9✔
348
            if missing.shape[0] > 0:
9✔
349
                raise Exception(
×
350
                    "Pst.res: the following observations "
351
                    + "were not found in "
352
                    + "{0}:{1}".format(self.resfile, ",".join(missing))
353
                )
354
            self.__res = res
9✔
355
            return self.__res
9✔
356

357
    @property
9✔
358
    def nprior(self):
9✔
359
        """number of prior information equations
360

361
        Returns:
362
            `int`: the number of prior info equations
363

364
        """
365
        self.control_data.nprior = self.prior_information.shape[0]
9✔
366
        return self.control_data.nprior
9✔
367

368
    @property
9✔
369
    def nnz_obs(self):
9✔
370
        """get the number of non-zero weighted observations
371

372
        Returns:
373
            `int`: the number of non-zeros weighted observations
374

375
        """
376
        nnz = 0
9✔
377
        for w in self.observation_data.weight:
9✔
378
            if w > 0.0:
9✔
379
                nnz += 1
9✔
380
        return nnz
9✔
381

382
    @property
9✔
383
    def nobs(self):
9✔
384
        """get the number of observations
385

386
        Returns:
387
            `int`: the number of observations
388

389
        """
390
        self.control_data.nobs = self.observation_data.shape[0]
9✔
391
        return self.control_data.nobs
9✔
392

393
    @property
9✔
394
    def npar_adj(self):
9✔
395
        """get the number of adjustable parameters (not fixed or tied)
396

397
        Returns:
398
            `int`: the number of adjustable parameters
399

400
        """
401
        pass
4✔
402
        np = 0
9✔
403
        for t in self.parameter_data.partrans:
9✔
404
            if t not in ["fixed", "tied"]:
9✔
405
                np += 1
9✔
406
        return np
9✔
407

408
    @property
9✔
409
    def npar(self):
9✔
410
        """get number of parameters
411

412
        Returns:
413
            `int`: the number of parameters
414

415
        """
416
        self.control_data.npar = self.parameter_data.shape[0]
9✔
417
        return self.control_data.npar
9✔
418

419
    @property
9✔
420
    def forecast_names(self):
9✔
421
        """get the forecast names from the pestpp options (if any).
422
        Returns None if no forecasts are named
423

424
        Returns:
425
            [`str`]: a list of forecast names.
426

427
        """
428
        if "forecasts" in self.pestpp_options.keys():
9✔
429
            if isinstance(self.pestpp_options["forecasts"], str):
9✔
430
                return self.pestpp_options["forecasts"].lower().split(",")
9✔
431
            else:
432
                return [f.lower() for f in self.pestpp_options["forecasts"]]
×
433
        elif "predictions" in self.pestpp_options.keys():
9✔
434
            if isinstance(self.pestpp_options["predictions"], str):
8✔
435
                return self.pestpp_options["predictions"].lower().split(",")
8✔
436
            else:
437
                return [f.lower() for f in self.pestpp_options["predictions"]]
8✔
438
        else:
439
            return None
9✔
440

441
    @property
9✔
442
    def obs_groups(self):
9✔
443
        """get the observation groups
444

445
        Returns:
446
            [`str`]: a list of unique observation groups
447

448
        """
449
        og = self.observation_data.obgnme.unique().tolist()
9✔
450
        return og
9✔
451

452
    @property
9✔
453
    def nnz_obs_groups(self):
9✔
454
        """get the observation groups that contain at least one non-zero weighted
455
         observation
456

457
        Returns:
458
            [`str`]: a list of observation groups that contain at
459
            least one non-zero weighted observation
460

461
        """
462
        obs = self.observation_data
8✔
463
        og = obs.loc[obs.weight > 0.0, "obgnme"].unique().tolist()
8✔
464
        return og
8✔
465

466
    @property
9✔
467
    def adj_par_groups(self):
9✔
468
        """get the parameter groups with atleast one adjustable parameter
469

470
        Returns:
471
            [`str`]: a list of parameter groups with
472
            at least one adjustable parameter
473

474
        """
475
        par = self.parameter_data
8✔
476
        tf = set(["tied", "fixed"])
8✔
477
        adj_pargp = par.loc[par.partrans.apply(lambda x: x not in tf), "pargp"].unique()
8✔
478
        return adj_pargp.tolist()
8✔
479

480
    @property
9✔
481
    def par_groups(self):
9✔
482
        """get the parameter groups
483

484
        Returns:
485
            [`str`]: a list of parameter groups
486

487
        """
488
        return self.parameter_data.pargp.unique().tolist()
9✔
489

490
    @property
9✔
491
    def prior_groups(self):
9✔
492
        """get the prior info groups
493

494
        Returns:
495
            [`str`]: a list of prior information groups
496

497
        """
498
        og = self.prior_information.obgnme.unique().tolist()
9✔
499
        return og
9✔
500

501
    @property
9✔
502
    def prior_names(self):
9✔
503
        """get the prior information names
504

505
        Returns:
506
            [`str`]: a list of prior information names
507

508
        """
509
        return self.prior_information.pilbl.tolist()
9✔
510

511
    @property
9✔
512
    def par_names(self):
9✔
513
        """get the parameter names
514

515
        Returns:
516
            [`str`]: a list of parameter names
517
        """
518
        return self.parameter_data.parnme.tolist()
9✔
519

520
    @property
9✔
521
    def adj_par_names(self):
9✔
522
        """get the adjustable (not fixed or tied) parameter names
523

524
        Returns:
525
            [`str`]: list of adjustable (not fixed or tied)
526
            parameter names
527

528
        """
529
        par = self.parameter_data
9✔
530
        tf = set(["tied", "fixed"])
9✔
531
        adj_names = par.loc[par.partrans.apply(lambda x: x not in tf), "parnme"]
9✔
532
        return adj_names.tolist()
9✔
533

534
    @property
9✔
535
    def obs_names(self):
9✔
536
        """get the observation names
537

538
        Returns:
539
            [`str`]: a list of observation names
540

541
        """
542
        return self.observation_data.obsnme.tolist()
9✔
543

544
    @property
9✔
545
    def nnz_obs_names(self):
9✔
546
        """get the non-zero weight observation names
547

548
        Returns:
549
            [`str`]: a list of non-zero weighted observation names
550

551
        """
552
        obs = self.observation_data
9✔
553
        nz_names = obs.loc[obs.weight > 0.0, "obsnme"]
9✔
554
        return nz_names.tolist()
9✔
555

556
    @property
9✔
557
    def zero_weight_obs_names(self):
9✔
558
        """get the zero-weighted observation names
559

560
        Returns:
561
            [`str`]: a list of zero-weighted observation names
562

563
        """
564
        obs = self.observation_data
9✔
565
        return obs.loc[obs.weight == 0.0, "obsnme"].tolist()
9✔
566

567
    @property
9✔
568
    def estimation(self):
9✔
569
        """check if the control_data.pestmode is set to estimation
570

571
        Returns:
572
            `bool`: True if `control_data.pestmode` is estmation, False otherwise
573

574
        """
575
        return self.control_data.pestmode == "estimation"
9✔
576

577
    @property
9✔
578
    def tied(self):
9✔
579
        """list of tied parameter names
580

581
        Returns:
582
            `pandas.DataFrame`: a dataframe of tied parameter information.
583
            Columns of `tied` are `parnme` and `partied`.  Returns `None` if
584
            no tied parameters are found.
585

586
        """
587
        par = self.parameter_data
9✔
588
        tied_pars = par.loc[par.partrans == "tied", "parnme"]
9✔
589
        if tied_pars.shape[0] == 0:
9✔
590
            return None
9✔
591
        if "partied" not in par.columns:
8✔
592
            par.loc[:, "partied"] = np.NaN
8✔
593
        tied = par.loc[tied_pars, ["parnme", "partied"]]
8✔
594
        return tied
8✔
595

596
    @property
9✔
597
    def template_files(self):
9✔
598
        """list of template file names
599

600
        Returns:
601
            `[str]`: a list of template file names, extracted from
602
                `Pst.model_input_data.pest_file`.  Returns `None` if this
603
                attribute is `None`
604

605
        Note:
606
            Use `Pst.model_input_data` to access the template-input file information for writing/modification
607

608
        """
609
        if (
9✔
610
            self.model_input_data is not None
611
            and "pest_file" in self.model_input_data.columns
612
        ):
613
            return self.model_input_data.pest_file.to_list()
9✔
614
        else:
615
            return None
×
616

617
    @property
9✔
618
    def input_files(self):
9✔
619
        """list of model input file names
620

621
        Returns:
622
            `[str]`: a list of model input file names, extracted from
623
                `Pst.model_input_data.model_file`.  Returns `None` if this
624
                attribute is `None`
625

626
        Note:
627
            Use `Pst.model_input_data` to access the template-input file information for writing/modification
628

629
        """
630
        if (
8✔
631
            self.model_input_data is not None
632
            and "model_file" in self.model_input_data.columns
633
        ):
634
            return self.model_input_data.model_file.to_list()
8✔
635
        else:
636
            return None
×
637

638
    @property
9✔
639
    def instruction_files(self):
9✔
640
        """list of instruction file names
641
        Returns:
642
            `[str]`: a list of instruction file names, extracted from
643
                `Pst.model_output_data.pest_file`.  Returns `None` if this
644
                 attribute is `None`
645

646
        Note:
647
            Use `Pst.model_output_data` to access the instruction-output file information for writing/modification
648

649
        """
650
        if (
9✔
651
            self.model_output_data is not None
652
            and "pest_file" in self.model_output_data.columns
653
        ):
654
            return self.model_output_data.pest_file.to_list()
9✔
655
        else:
656
            return None
×
657

658
    @property
9✔
659
    def output_files(self):
9✔
660
        """list of model output file names
661
        Returns:
662
            `[str]`: a list of model output file names, extracted from
663
                `Pst.model_output_data.model_file`.  Returns `None` if this
664
                attribute is `None`
665

666
        Note:
667
            Use `Pst.model_output_data` to access the instruction-output file information for writing/modification
668

669
        """
670
        if (
9✔
671
            self.model_output_data is not None
672
            and "model_file" in self.model_output_data.columns
673
        ):
674
            return self.model_output_data.model_file.to_list()
9✔
675
        else:
676
            return None
×
677

678
    @staticmethod
9✔
679
    def _read_df(f, nrows, names, converters, defaults=None):  # todo : drop method? seems to not be used?
9✔
680
        """a private method to read part of an open file into a pandas.DataFrame.
681

682
        Args:
683
            f (`file`): open file handle
684
            nrows (`int`): number of rows to read
685
            names ([`str`]): names to set the columns of the dataframe with
686
            converters (`dict`): dictionary of lambda functions to convert strings
687
                to numerical format
688
            defaults (`dict`): dictionary of default values to assign columns.
689
                Default is None
690

691
        Returns:
692
            `pandas.DataFrame`: dataframe of control file section info
693

694
        """
695
        seek_point = f.tell()
×
696
        line = f.readline()
×
697
        raw = line.strip().split()
×
698
        if raw[0].lower() == "external":
×
699
            filename = raw[1]
×
700
            if not os.path.exists(filename):
×
701
                raise Exception(
×
702
                    "Pst._read_df() error: external file '{0}' not found".format(
703
                        filename
704
                    )
705
                )
706
            df = pd.read_csv(filename, index_col=False, comment="#",low_memory=False)
×
707
            df.columns = df.columns.str.lower()
×
708
            for name in names:
×
709
                if name not in df.columns:
×
710
                    raise Exception(
×
711
                        "Pst._read_df() error: name"
712
                        + "'{0}' not in external file '{1}' columns".format(
713
                            name, filename
714
                        )
715
                    )
716
                if name in converters:
×
717
                    df.loc[:, name] = df.loc[:, name].apply(converters[name])
×
718
            if defaults is not None:
×
719
                for name in names:
×
720
                    df.loc[:, name] = df.loc[:, name].fillna(defaults[name])
×
721
        else:
722
            if nrows is None:
×
723
                raise Exception(
×
724
                    "Pst._read_df() error: non-external sections require nrows"
725
                )
726
            f.seek(seek_point)
×
727
            df = pd.read_csv(
×
728
                f,
729
                header=None,
730
                names=names,
731
                nrows=nrows,
732
                delim_whitespace=True,
733
                converters=converters,
734
                index_col=False,
735
                comment="#",
736
                low_memory = False
737
            )
738

739
            # in case there was some extra junk at the end of the lines
740
            if df.shape[1] > len(names):
×
741
                df = df.iloc[:, len(names)]
×
742
                df.columns = names
×
743

744
            if defaults is not None:
×
745
                for name in names:
×
746
                    df.loc[:, name] = df.loc[:, name].fillna(defaults[name])
×
747

748
            elif np.any(pd.isnull(df).values.flatten()):
×
749
                raise Exception("NANs found")
×
750
            f.seek(seek_point)
×
751
            extras = []
×
752
            for _ in range(nrows):
×
753
                line = f.readline()
×
754
                extra = np.NaN
×
755
                if "#" in line:
×
756
                    raw = line.strip().split("#")
×
757
                    extra = " # ".join(raw[1:])
×
758
                extras.append(extra)
×
759

760
            df.loc[:, "extra"] = extras
×
761

762
        return df
×
763

764
    def _read_line_comments(self, f, forgive):
9✔
765
        """private method to read comment lines from a control file"""
766
        comments = []
9✔
767
        while True:
4✔
768
            org_line = f.readline()
9✔
769
            line = org_line.lower().strip()
9✔
770

771
            self.lcount += 1
9✔
772
            if org_line == "":
9✔
773
                if forgive:
9✔
774
                    return None, comments
9✔
775
                else:
776
                    raise Exception("unexpected EOF")
×
777
            if line.startswith("++") and line.split("++")[1].strip()[0] != "#":
9✔
778
                self._parse_pestpp_line(line)
9✔
779
            elif "++" in line:
9✔
780
                comments.append(line.strip())
9✔
781
            elif line.startswith("#"):
9✔
782
                comments.append(line.strip())
8✔
783
            else:
784
                break
4✔
785
        return org_line.strip(), comments
9✔
786

787
    def _read_section_comments(self, f, forgive):
9✔
788
        """private method to read comments from a section of the control file"""
789
        lines = []
9✔
790
        section_comments = []
9✔
791
        while True:
4✔
792
            line, comments = self._read_line_comments(f, forgive)
9✔
793
            section_comments.extend(comments)
9✔
794
            if line is None or line.startswith("*"):
9✔
795
                break
9✔
796
            if len(line.strip()) == 0:
9✔
797
                continue
8✔
798

799
            lines.append(line)
9✔
800
        return line, lines, section_comments
9✔
801

802
    @staticmethod
9✔
803
    def _parse_external_line(line, pst_path="."):
9✔
804
        """private method to parse a file for external file info"""
805
        raw = line.strip().split()
8✔
806
        existing_path, filename = Pst._parse_path_agnostic(raw[0])
8✔
807
        if pst_path is not None:
8✔
808
            if pst_path != ".":
8✔
809
                filename = os.path.join(pst_path, filename)
8✔
810
        else:
811
            filename = os.path.join(existing_path, filename)
×
812

813
        raw = line.lower().strip().split()
8✔
814
        options = {}
8✔
815
        if len(raw) > 1:
8✔
816
            if len(raw) % 2 == 0:
8✔
817
                s = "wrong number of entries on 'external' line:'{0}\n".format(line)
×
818
                s += "Should include 'filename', then pairs of key-value options"
×
819
                raise Exception(s)
×
820
            options = {k.lower(): v.lower() for k, v in zip(raw[1:-1], raw[2:])}
8✔
821
        return filename, options
8✔
822

823
    @staticmethod
9✔
824
    def _parse_path_agnostic(filename):
9✔
825
        """private method to parse a file path for any os sep"""
826
        filename = filename.replace("\\", os.sep).replace("/", os.sep)
9✔
827
        return os.path.split(filename)
9✔
828

829
    @staticmethod
9✔
830
    def _cast_df_from_lines(
9✔
831
        section, lines, fieldnames, converters, defaults, alias_map={}, pst_path="."
832
    ):
833
        """private method to cast a pandas dataframe from raw control file lines"""
834
        # raw = lines[0].strip().split()
835
        # if raw[0].lower() == "external":
836
        if section.lower().strip().split()[-1] == "external":
9✔
837
            dfs = []
8✔
838
            for line in lines:
8✔
839
                filename, options = Pst._parse_external_line(line, pst_path)
8✔
840
                if not os.path.exists(filename):
8✔
841
                    raise Exception(
×
842
                        "Pst._cast_df_from_lines() error: external file '{0}' not found".format(
843
                            filename
844
                        )
845
                    )
846
                sep = options.get("sep", ",")
8✔
847

848
                missing_vals = options.get("missing_values", None)
8✔
849
                if sep.lower() == "w":
8✔
850
                    df = pd.read_csv(
8✔
851
                        filename, delim_whitespace=True,
852
                        na_values=missing_vals,
853
                        low_memory=False
854
                    )
855
                else:
856
                    df = pd.read_csv(filename, sep=sep,
8✔
857
                                     na_values=missing_vals,
858
                                     low_memory=False)
859
                df.columns = df.columns.str.lower()
8✔
860
                for easy, hard in alias_map.items():
8✔
861
                    if easy in df.columns and hard in df.columns:
8✔
862
                        raise Exception(
×
863
                            "fieldname '{0}' and its alias '{1}' both in '{2}'".format(
864
                                hard, easy, filename
865
                            )
866
                        )
867
                    if easy in df.columns:
8✔
868
                        df.loc[:, hard] = df.pop(easy)
8✔
869
                dfs.append(df)
8✔
870

871
            df = pd.concat(dfs, axis=0, ignore_index=True)
8✔
872

873
        else:
874
            extra = []
9✔
875
            raw = []
9✔
876

877
            for iline, line in enumerate(lines):
9✔
878
                line = line.lower()
9✔
879
                if "#" in line:
9✔
880
                    er = line.strip().split("#")
8✔
881
                    extra.append("#".join(er[1:]))
8✔
882
                    r = er[0].split()
8✔
883
                else:
884
                    r = line.strip().split()
9✔
885
                    extra.append(np.NaN)
9✔
886

887
                raw.append(r[: len(defaults)])
9✔
888

889
            found_fieldnames = fieldnames[: len(raw[0])]
9✔
890
            df = pd.DataFrame(raw, columns=found_fieldnames)
9✔
891

892
            df.loc[:, "extra"] = extra
9✔
893

894
        for col in fieldnames:
9✔
895
            if col not in df.columns:
9✔
896
                df.loc[:, col] = np.NaN
9✔
897
            if col in defaults:
9✔
898
                df.loc[:, col] = df.loc[:, col].fillna(defaults[col])
9✔
899
            if col in converters:
9✔
900
                # pandas 2.0 `df.loc[:, col] = df.loc[:, col].astype(int)` type
901
                # assignment cast RHS to LHS dtype -- therefore did not change
902
                # LHS dtype -- this broke shit.
903
                # Using `df[col] =` assignment instead.
904
                df[col] = df.loc[:, col].astype(str).apply(converters[col])
9✔
905
        return df
9✔
906

907
    def _cast_prior_df_from_lines(self, section, lines, pst_path="."):
9✔
908
        """private method to cast prior information lines to a dataframe"""
909
        if pst_path == ".":
9✔
910
            pst_path = ""
×
911
        if section.strip().split()[-1].lower() == "external":
9✔
912
            dfs = []
8✔
913
            for line in lines:
8✔
914
                filename, options = Pst._parse_external_line(line, pst_path)
8✔
915
                if not os.path.exists(filename):
8✔
916
                    raise Exception(
×
917
                        "Pst._cast_prior_df_from_lines() error: external file '{0}' not found".format(
918
                            filename
919
                        )
920
                    )
921
                sep = options.get("sep", ",")
8✔
922

923
                missing_vals = options.get("missing_values", None)
8✔
924
                if sep.lower() == "w":
8✔
925
                    df = pd.read_csv(
×
926
                        filename, delim_whitespace=True, na_values=missing_vals,low_memory=False
927
                    )
928
                else:
929
                    df = pd.read_csv(filename, sep=sep, na_values=missing_vals,low_memory=False)
8✔
930
                df.columns = df.columns.str.lower()
8✔
931

932
                for field in pst_utils.pst_config["prior_fieldnames"]:
8✔
933
                    if field not in df.columns:
8✔
934
                        raise Exception(
×
935
                            "Pst._cast_prior_df_from_lines() error: external file"
936
                            + "'{0}' missing required field '{1}'".format(
937
                                filename, field
938
                            )
939
                        )
940
                dfs.append(df)
8✔
941
            df = pd.concat(dfs, axis=0, ignore_index=True)
8✔
942
            self.prior_information = df
8✔
943
            self.prior_information.index = self.prior_information.pilbl
8✔
944

945
        else:
946
            pilbl, obgnme, weight, equation = [], [], [], []
9✔
947
            extra = []
9✔
948
            for line in lines:
9✔
949
                if "#" in line:
9✔
950
                    er = line.split("#")
×
951
                    raw = er[0].split()
×
952
                    extra.append("#".join(er[1:]))
×
953
                else:
954
                    extra.append(np.NaN)
9✔
955
                    raw = line.split()
9✔
956
                pilbl.append(raw[0].lower())
9✔
957
                obgnme.append(raw[-1].lower())
9✔
958
                weight.append(float(raw[-2]))
9✔
959
                eq = " ".join(raw[1:-2])
9✔
960
                equation.append(eq)
9✔
961

962
            self.prior_information = pd.DataFrame(
9✔
963
                {
964
                    "pilbl": pilbl,
965
                    "equation": equation,
966
                    "weight": weight,
967
                    "obgnme": obgnme,
968
                }
969
            )
970
            self.prior_information.index = self.prior_information.pilbl
9✔
971
            self.prior_information.loc[:, "extra"] = extra
9✔
972

973
    def _load_version2(self, filename):
9✔
974
        """private method to load a version 2 control file"""
975
        self.lcount = 0
9✔
976
        self.comments = {}
9✔
977
        self.prior_information = self.null_prior
9✔
978
        assert os.path.exists(filename), "couldn't find control file {0}".format(
9✔
979
            filename
980
        )
981
        f = open(filename, "r")
9✔
982
        try:
9✔
983
            pst_path, _ = Pst._parse_path_agnostic(filename)
9✔
984
            last_section = ""
9✔
985
            req_sections = {
9✔
986
                "* parameter data",
987
                "* observation data",
988
                "* model command line",
989
                "* control data",
990
            }
991
            sections_found = set()
9✔
992
            while True:
4✔
993

994
                next_section, section_lines, comments = self._read_section_comments(f, True)
9✔
995

996
                if "* control data" in last_section.lower():
9✔
997
                    iskeyword = False
9✔
998
                    if "keyword" in last_section.lower():
9✔
999
                        iskeyword = True
8✔
1000
                    self.pestpp_options = self.control_data.parse_values_from_lines(
9✔
1001
                        section_lines, iskeyword=iskeyword
1002
                    )
1003
                    if len(self.pestpp_options) > 0:
9✔
1004
                        ppo = self.pestpp_options
8✔
1005
                        svd_opts = ["svdmode", "eigthresh", "maxsing", "eigwrite"]
8✔
1006
                        for svd_opt in svd_opts:
8✔
1007
                            if svd_opt in ppo:
8✔
1008
                                self.svd_data.__setattr__(svd_opt, ppo.pop(svd_opt))
8✔
1009
                        for reg_opt in self.reg_data.should_write:
8✔
1010
                            if reg_opt in ppo:
8✔
1011
                                self.reg_data.__setattr__(reg_opt, ppo.pop(reg_opt))
8✔
1012

1013
                elif "* singular value decomposition" in last_section.lower():
9✔
1014
                    self.svd_data.parse_values_from_lines(section_lines)
9✔
1015

1016
                elif "* observation groups" in last_section.lower():
9✔
1017
                    pass
9✔
1018

1019
                elif "* parameter groups" in last_section.lower():
9✔
1020
                    self.parameter_groups = self._cast_df_from_lines(
9✔
1021
                        last_section,
1022
                        section_lines,
1023
                        self.pargp_fieldnames,
1024
                        self.pargp_converters,
1025
                        self.pargp_defaults,
1026
                        pst_path=pst_path,
1027
                    )
1028
                    self.parameter_groups.index = self.parameter_groups.pargpnme
9✔
1029

1030
                elif "* parameter data" in last_section.lower():
9✔
1031
                    # check for tied pars
1032
                    ntied = 0
9✔
1033
                    if "external" not in last_section.lower():
9✔
1034
                        for line in section_lines:
9✔
1035
                            if "tied" in line.lower():
9✔
1036
                                ntied += 1
8✔
1037
                    if ntied > 0:
9✔
1038
                        slines = section_lines[:-ntied]
8✔
1039
                    else:
1040
                        slines = section_lines
9✔
1041
                    self.parameter_data = self._cast_df_from_lines(
9✔
1042
                        last_section,
1043
                        slines,
1044
                        self.par_fieldnames,
1045
                        self.par_converters,
1046
                        self.par_defaults,
1047
                        self.par_alias_map,
1048
                        pst_path=pst_path,
1049
                    )
1050

1051
                    self.parameter_data.index = self.parameter_data.parnme
9✔
1052
                    if ntied > 0:
9✔
1053
                        tied_pars, partied = [], []
8✔
1054
                        for line in section_lines[-ntied:]:
8✔
1055
                            raw = line.strip().split()
8✔
1056
                            tied_pars.append(raw[0].strip().lower())
8✔
1057
                            partied.append(raw[1].strip().lower())
8✔
1058
                        self.parameter_data.loc[:, "partied"] = np.NaN
8✔
1059
                        self.parameter_data.loc[tied_pars, "partied"] = partied
8✔
1060

1061
                elif "* observation data" in last_section.lower():
9✔
1062
                    self.observation_data = self._cast_df_from_lines(
9✔
1063
                        last_section,
1064
                        section_lines,
1065
                        self.obs_fieldnames,
1066
                        self.obs_converters,
1067
                        self.obs_defaults,
1068
                        alias_map=self.obs_alias_map,
1069
                        pst_path=pst_path,
1070
                    )
1071
                    self.observation_data.index = self.observation_data.obsnme
9✔
1072

1073
                elif "* model command line" in last_section.lower():
9✔
1074
                    for line in section_lines:
9✔
1075
                        self.model_command.append(line.strip())
9✔
1076

1077
                elif "* model input/output" in last_section.lower():
9✔
1078
                    if "* control data" not in sections_found:
9✔
1079
                        raise Exception(
×
1080
                            "attempting to read '* model input/output' before reading "
1081
                            + "'* control data' - need NTPLFLE counter for this..."
1082
                        )
1083
                    if (
9✔
1084
                        len(section_lines)
1085
                        != self.control_data.ntplfle + self.control_data.ninsfle
1086
                    ):
1087
                        raise Exception(
×
1088
                            "didnt find the right number of '* model input/output' lines,"
1089
                            + "expecting {0} template files and {1} instruction files".format(
1090
                                self.control_data.ntplfle, self.control_data.ninsfle
1091
                            )
1092
                        )
1093
                    template_files, input_files = [], []
9✔
1094
                    for i in range(self.control_data.ntplfle):
9✔
1095
                        raw = section_lines[i].strip().split()
9✔
1096
                        template_files.append(raw[0])
9✔
1097
                        input_files.append(raw[1])
9✔
1098
                    self.model_input_data = pd.DataFrame(
9✔
1099
                        {"pest_file": template_files, "model_file": input_files},
1100
                        index=template_files,
1101
                    )
1102

1103
                    instruction_files, output_files = [], []
9✔
1104
                    for j in range(self.control_data.ninsfle):
9✔
1105
                        raw = section_lines[i + j + 1].strip().split()
9✔
1106
                        instruction_files.append(raw[0])
9✔
1107
                        output_files.append(raw[1])
9✔
1108
                    self.model_output_data = pd.DataFrame(
9✔
1109
                        {"pest_file": instruction_files, "model_file": output_files},
1110
                        index=instruction_files,
1111
                    )
1112

1113
                elif "* model input" in last_section.lower():
9✔
1114
                    if last_section.strip().split()[-1].lower() == "external":
8✔
1115
                        self.model_input_data = self._cast_df_from_lines(
8✔
1116
                            last_section,
1117
                            section_lines,
1118
                            ["pest_file", "model_file"],
1119
                            [],
1120
                            [],
1121
                            pst_path=pst_path,
1122
                        )
1123
                        # self.template_files.extend(io_df.pest_file.tolist())
1124
                        # self.input_files.extend(io_df.model_file.tolist())
1125

1126
                    else:
1127
                        template_files, input_files = [], []
×
1128
                        for line in section_lines:
×
1129
                            raw = line.split()
×
1130
                            template_files.append(raw[0])
×
1131
                            input_files.append(raw[1])
×
1132
                        self.model_input_data = pd.DataFrame(
×
1133
                            {"pest_file": template_files, "model_file": input_files},
1134
                            index=template_files,
1135
                        )
1136

1137
                elif "* model output" in last_section.lower():
9✔
1138
                    if last_section.strip().split()[-1].lower() == "external":
8✔
1139
                        self.model_output_data = self._cast_df_from_lines(
8✔
1140
                            last_section,
1141
                            section_lines,
1142
                            ["pest_file", "model_file"],
1143
                            [],
1144
                            [],
1145
                            pst_path=pst_path,
1146
                        )
1147
                        # self.instruction_files.extend(io_df.pest_file.tolist())
1148
                        # self.output_files.extend(io_df.model_file.tolist())
1149

1150
                    else:
1151
                        instruction_files, output_files = [], []
×
1152
                        for iline, line in enumerate(section_lines):
×
1153
                            raw = line.split()
×
1154
                            instruction_files.append(raw[0])
×
1155
                            output_files.append(raw[1])
×
1156
                        self.model_output_data = pd.DataFrame(
×
1157
                            {"pest_file": instruction_files, "model_file": output_files},
1158
                            index=instruction_files,
1159
                        )
1160

1161
                elif "* prior information" in last_section.lower():
9✔
1162
                    self._cast_prior_df_from_lines(
9✔
1163
                        last_section, section_lines, pst_path=pst_path
1164
                    )
1165
                    # self.prior_information = Pst._cast_df_from_lines(last_section,section_lines,self.prior_fieldnames,
1166
                    #                                                 self.prior_format,{},pst_path=pst_path)
1167

1168
                elif (
9✔
1169
                    last_section.lower() == "* regularization"
1170
                    or last_section.lower() == "* regularisation"
1171
                ):
1172
                    raw = section_lines[0].strip().split()
8✔
1173
                    self.reg_data.phimlim = float(raw[0])
8✔
1174
                    self.reg_data.phimaccept = float(raw[1])
8✔
1175
                    raw = section_lines[1].strip().split()
8✔
1176
                    self.reg_data.wfinit = float(raw[0])
8✔
1177

1178
                elif len(last_section) > 0:
9✔
1179
                    print(
8✔
1180
                        "Pst._load_version2() warning: unrecognized section: ", last_section
1181
                    )
1182
                    self.comments[last_section] = section_lines
8✔
1183

1184
                if next_section is None or len(section_lines) == 0:
9✔
1185
                    break
8✔
1186
                next_section_generic = (
9✔
1187
                    next_section.replace("external", "")
1188
                    .replace("keyword", "")
1189
                    .strip()
1190
                    .lower()
1191
                )
1192
                if next_section_generic in sections_found:
9✔
1193
                    f.close()
8✔
1194
                    raise Exception(
8✔
1195
                        "duplicate control file sections for '{0}'".format(
1196
                            next_section_generic
1197
                        )
1198
                    )
1199
                sections_found.add(next_section_generic)
9✔
1200

1201
                last_section = next_section
9✔
1202
        except Exception as e:
8✔
1203
            f.close()
8✔
1204
            raise Exception("error reading ctrl file '{0}': {1}".format(filename,str(e)))
8✔
1205

1206
        f.close()
9✔
1207
        not_found = []
9✔
1208
        for section in req_sections:
9✔
1209
            if section not in sections_found:
9✔
1210
                not_found.append(section)
×
1211
        if len(not_found) > 0:
9✔
1212
            raise Exception(
×
1213
                "Pst._load_version2() error: the following required sections were "
1214
                + "not found:{0}".format(",".join(not_found))
1215
            )
1216
        if "* model input/output" in sections_found and (
9✔
1217
            "* model input" in sections_found or "* model output" in sections_found
1218
        ):
1219
            raise Exception(
×
1220
                "'* model input/output cant be used with '* model input' or '* model output'"
1221
            )
1222

1223
    def load(self, filename):
9✔
1224
        """entry point load the pest control file.
1225

1226
        Args:
1227
            filename (`str`): pst filename
1228

1229
        Note:
1230
            This method is called from the `Pst` construtor unless the `load` arg is `False`.
1231

1232

1233

1234
        """
1235
        if not os.path.exists(filename):
9✔
1236
            raise Exception("couldn't find control file {0}".format(filename))
×
1237
        f = open(filename, "r")
9✔
1238

1239
        while True:
4✔
1240
            line = f.readline()
9✔
1241
            if line == "":
9✔
1242
                raise Exception(
×
1243
                    "Pst.load() error: EOF when trying to find first line - #sad"
1244
                )
1245
            if line.strip().split()[0].lower() == "pcf":
9✔
1246
                break
9✔
1247
        if not line.startswith("pcf"):
9✔
1248
            raise Exception(
×
1249
                "Pst.load() error: first non-comment line must start with 'pcf', not '{0}'".format(
1250
                    line
1251
                )
1252
            )
1253

1254
        self._load_version2(filename)
9✔
1255
        self._try_load_longnames()
9✔
1256
        self.try_parse_name_metadata()
9✔
1257
        self._reset_file_paths_os()
9✔
1258

1259
    def _reset_file_paths_os(self):
9✔
1260
        for df in [self.model_output_data,self.model_input_data]:
9✔
1261
            for col in ["pest_file","model_file"]:
9✔
1262
                df.loc[:,col] = df.loc[:,col].apply(lambda x: os.path.sep.join(x.replace("\\","/").split("/")))
9✔
1263

1264
    def _try_load_longnames(self):
9✔
1265
        from pathlib import Path
9✔
1266
        d = Path(self.filename).parent
9✔
1267
        for df, fnme in ((self.parameter_data, "parlongname.map"),
9✔
1268
                         (self.observation_data, "obslongname.map")):
1269
            try:
9✔
1270
                mapr = pd.read_csv(Path(d, fnme), index_col=0,low_memory=False)['longname']
9✔
1271
                df['longname'] = df.index.map(mapr.to_dict())
8✔
1272
            except Exception:
9✔
1273
                pass
9✔
1274
        if hasattr(self, "parameter_groups"):
9✔
1275
            df, fnme = (self.parameter_groups, "pglongname.map")
9✔
1276
            try:
9✔
1277
                mapr = pd.read_csv(Path(d, fnme), index_col=0,low_memory=False)['longname']
9✔
1278
                df['longname'] = df.index.map(mapr.to_dict())
×
1279
            except Exception:
9✔
1280
                pass
9✔
1281

1282

1283
    def _parse_pestpp_line(self, line):
9✔
1284
        # args = line.replace('++','').strip().split()
1285
        args = line.replace("++", "").strip().split(")")
9✔
1286
        args = [a for a in args if a != ""]
9✔
1287
        # args = ['++'+arg.strip() for arg in args]
1288
        # self.pestpp_lines.extend(args)
1289
        keys = [arg.split("(")[0] for arg in args]
9✔
1290
        values = [arg.split("(")[1].replace(")", "") for arg in args if "(" in arg]
9✔
1291
        for _ in range(len(values) - 1, len(keys)):
9✔
1292
            values.append("")
9✔
1293
        for key, value in zip(keys, values):
9✔
1294
            if key in self.pestpp_options:
9✔
1295
                print(
×
1296
                    "Pst.load() warning: duplicate pest++ option found:" + str(key),
1297
                    PyemuWarning,
1298
                )
1299
            self.pestpp_options[key.lower()] = value
9✔
1300

1301
    def _update_control_section(self):
9✔
1302
        """private method to synchronize the control section counters with the
1303
        various parts of the control file.  This is usually called during the
1304
        Pst.write() method.
1305

1306
        """
1307
        self.control_data.npar = self.npar
9✔
1308
        self.control_data.nobs = self.nobs
9✔
1309
        self.control_data.npargp = self.parameter_groups.shape[0]
9✔
1310
        self.control_data.nobsgp = (
9✔
1311
            self.observation_data.obgnme.value_counts().shape[0]
1312
            + self.prior_information.obgnme.value_counts().shape[0]
1313
        )
1314

1315
        self.control_data.nprior = self.prior_information.shape[0]
9✔
1316
        # self.control_data.ntplfle = len(self.template_files)
1317
        self.control_data.ntplfle = self.model_input_data.shape[0]
9✔
1318
        # self.control_data.ninsfle = len(self.instruction_files)
1319
        self.control_data.ninsfle = self.model_output_data.shape[0]
9✔
1320
        self.control_data.numcom = len(self.model_command)
9✔
1321

1322
    def rectify_pgroups(self):
9✔
1323
        """synchronize parameter groups section with the parameter data section
1324

1325
        Note:
1326
            This method is called during `Pst.write()` to make sure all parameter
1327
            groups named in `* parameter data` are included.  This is so users
1328
            don't have to manually keep this section up.  This method can also be
1329
            called during control file modifications to see what parameter groups
1330
            are present and prepare for modifying the default values in the `* parameter
1331
            group` section
1332

1333
        Example::
1334

1335
            pst = pyemu.Pst("my.pst")
1336
            pst.parameter_data.loc["par1","pargp"] = "new_group"
1337
            pst.rectify_groups()
1338
            pst.parameter_groups.loc["new_group","derinc"] = 1.0
1339

1340

1341
        """
1342
        # add any parameters groups
1343
        pdata_groups = list(self.parameter_data.loc[:, "pargp"].value_counts().keys())
9✔
1344
        # print(pdata_groups)
1345
        need_groups = []
9✔
1346

1347
        if hasattr(self, "parameter_groups"):
9✔
1348
            existing_groups = list(self.parameter_groups.pargpnme)
9✔
1349
        else:
1350
            existing_groups = []
9✔
1351
            self.parameter_groups = pd.DataFrame(columns=self.pargp_fieldnames)
9✔
1352

1353
        for pg in pdata_groups:
9✔
1354
            if pg not in existing_groups:
9✔
1355
                need_groups.append(pg)
9✔
1356
        if len(need_groups) > 0:
9✔
1357
            # print(need_groups)
1358
            defaults = copy.copy(pst_utils.pst_config["pargp_defaults"])
9✔
1359
            for grp in need_groups:
9✔
1360
                defaults["pargpnme"] = grp
9✔
1361
                self.parameter_groups = pd.concat(
9✔
1362
                    [self.parameter_groups, pd.DataFrame([defaults])],
1363
                    ignore_index=True
1364
                )
1365

1366
        # now drop any left over groups that aren't needed
1367
        for gp in self.parameter_groups.loc[:, "pargpnme"]:
9✔
1368
            if gp in pdata_groups and gp not in need_groups:
9✔
1369
                need_groups.append(gp)
9✔
1370
        self.parameter_groups.index = self.parameter_groups.pargpnme
9✔
1371
        self.parameter_groups = self.parameter_groups.loc[need_groups, :]
9✔
1372
        idx = self.parameter_groups.index.drop_duplicates()
9✔
1373
        if idx.shape[0] != self.parameter_groups.shape[0]:
9✔
1374
            warnings.warn("dropping duplicate parameter groups", PyemuWarning)
×
1375
            self.parameter_groups = self.parameter_groups.loc[
×
1376
                ~self.parameter_groups.index.duplicated(keep="first"), :
1377
            ]
1378

1379
    def _parse_pi_par_names(self):
9✔
1380
        """private method to get the parameter names from prior information
1381
        equations.  Sets a 'names' column in Pst.prior_information that is a list
1382
        of parameter names
1383

1384

1385
        """
1386
        if self.prior_information.shape[0] == 0:
9✔
1387
            return
×
1388
        if "names" in self.prior_information.columns:
9✔
1389
            self.prior_information.pop("names")
9✔
1390
        if "rhs" in self.prior_information.columns:
9✔
1391
            self.prior_information.pop("rhs")
×
1392

1393
        def parse(eqs):
9✔
1394
            raw = eqs.split("=")
9✔
1395
            # rhs = float(raw[1])
1396
            raw = [
9✔
1397
                i
1398
                for i in re.split(
1399
                    "[###]",
1400
                    raw[0].lower().strip().replace(" + ", "###").replace(" - ", "###"),
1401
                )
1402
                if i != ""
1403
            ]
1404
            # in case of a leading '-' or '+'
1405
            if len(raw[0]) == 0:
9✔
1406
                raw = raw[1:]
×
1407
            # pnames = []
1408
            # for r in raw:
1409
            #     if '*' not in r:
1410
            #         continue
1411
            #     pname =  r.split('*')[1].replace("log(", '').replace(')', '').strip()
1412
            #     pnames.append(pname)
1413
            # return pnames
1414
            return [
9✔
1415
                r.split("*")[1].replace("log(", "").replace(")", "").strip()
1416
                for r in raw
1417
                if "*" in r
1418
            ]
1419

1420
        self.prior_information.loc[:, "names"] = self.prior_information.equation.apply(
9✔
1421
            lambda x: parse(x)
1422
        )
1423

1424
    def add_pi_equation(
9✔
1425
        self,
1426
        par_names,
1427
        pilbl=None,
1428
        rhs=0.0,
1429
        weight=1.0,
1430
        obs_group="pi_obgnme",
1431
        coef_dict={},
1432
    ):
1433
        """a helper to construct a new prior information equation.
1434

1435
        Args:
1436
            par_names ([`str`]): parameter names in the equation
1437
            pilbl (`str`): name to assign the prior information equation.  If None,
1438
                a generic equation name is formed. Default is None
1439
            rhs (`float`): the right-hand side of the pi equation
1440
            weight (`float`): the weight of the equation
1441
            obs_group (`str`): the observation group for the equation. Default is 'pi_obgnme'
1442
            coef_dict (`dict`): a dictionary of parameter name, coefficient pairs to assign
1443
                leading coefficients for one or more parameters in the equation.
1444
                If a parameter is not listed, 1.0 is used for its coefficients.
1445
                Default is {}
1446

1447
        Example::
1448

1449
            pst = pyemu.Pst("pest.pst")
1450
            # add a pi equation for the first adjustable parameter
1451
            pst.add_pi_equation(pst.adj_par_names[0],pilbl="pi1",rhs=1.0)
1452
            # add a pi equation for 1.5 times the 2nd and 3 times the 3rd adj pars to sum together to 2.0
1453
            names = pst.adj_par_names[[1,2]]
1454
            pst.add_pi_equation(names,coef_dict={names[0]:1.5,names[1]:3})
1455

1456

1457
        """
1458
        if pilbl is None:
8✔
1459
            pilbl = "pilbl_{0}".format(self.__pi_count)
8✔
1460
            self.__pi_count += 1
8✔
1461
        missing, fixed = [], []
8✔
1462

1463
        for par_name in par_names:
8✔
1464
            if par_name not in self.parameter_data.parnme:
8✔
1465
                missing.append(par_name)
×
1466
            elif self.parameter_data.loc[par_name, "partrans"] in ["fixed", "tied"]:
8✔
1467
                fixed.append(par_name)
×
1468
        if len(missing) > 0:
8✔
1469
            raise Exception(
×
1470
                "Pst.add_pi_equation(): the following pars "
1471
                + " were not found: {0}".format(",".join(missing))
1472
            )
1473
        if len(fixed) > 0:
8✔
1474
            raise Exception(
×
1475
                "Pst.add_pi_equation(): the following pars "
1476
                + " were are fixed/tied: {0}".format(",".join(fixed))
1477
            )
1478
        eqs_str = ""
8✔
1479
        sign = ""
8✔
1480
        for i, par_name in enumerate(par_names):
8✔
1481
            coef = coef_dict.get(par_name, 1.0)
8✔
1482
            if coef < 0.0:
8✔
1483
                sign = "-"
8✔
1484
                coef = np.abs(coef)
8✔
1485
            elif i > 0:
8✔
1486
                sign = "+"
8✔
1487
            if self.parameter_data.loc[par_name, "partrans"] == "log":
8✔
1488
                par_name = "log({})".format(par_name)
8✔
1489
            eqs_str += " {0} {1} * {2} ".format(sign, coef, par_name)
8✔
1490
        eqs_str += " = {0}".format(rhs)
8✔
1491
        self.prior_information.loc[pilbl, "pilbl"] = pilbl
8✔
1492
        self.prior_information.loc[pilbl, "equation"] = eqs_str
8✔
1493
        self.prior_information.loc[pilbl, "weight"] = weight
8✔
1494
        self.prior_information.loc[pilbl, "obgnme"] = obs_group
8✔
1495

1496
    def rectify_pi(self):
9✔
1497
        """rectify the prior information equation with the current state of the
1498
        parameter_data dataframe.
1499

1500

1501
        Note:
1502
            Equations that list fixed, tied or missing parameters
1503
            are removed completely even if adjustable parameters are also
1504
            listed in the equation. This method is called during Pst.write()
1505

1506
        """
1507
        if self.prior_information.shape[0] == 0:
9✔
1508
            return
9✔
1509
        self._parse_pi_par_names()
9✔
1510
        adj_names = self.adj_par_names
9✔
1511

1512
        def is_good(names):
9✔
1513
            for n in names:
9✔
1514
                if n not in adj_names:
9✔
1515
                    return False
9✔
1516
            return True
9✔
1517

1518
        keep_idx = self.prior_information.names.apply(lambda x: is_good(x))
9✔
1519
        self.prior_information = self.prior_information.loc[keep_idx, :]
9✔
1520

1521
    def _write_df(self, name, f, df, formatters, columns):
9✔
1522
        """private method to write a dataframe to a control file"""
1523
        if name.startswith("*"):
9✔
1524
            f.write(name + "\n")
9✔
1525
        if self.with_comments:
9✔
1526
            for line in self.comments.get(name, []):
8✔
1527
                f.write(line + "\n")
×
1528
        if df.loc[:, columns].isnull().values.any():
9✔
1529
            # warnings.warn("WARNING: NaNs in {0} dataframe".format(name))
1530
            csv_name = "pst.{0}.nans.csv".format(
8✔
1531
                name.replace(" ", "_").replace("*", "")
1532
            )
1533
            df.to_csv(csv_name)
8✔
1534
            raise Exception(
8✔
1535
                "NaNs in {0} dataframe, csv written to {1}".format(name, csv_name)
1536
            )
1537

1538
        def ext_fmt(x):
9✔
1539
            if pd.notnull(x):
8✔
1540
                return " # {0}".format(x)
8✔
1541
            return ""
8✔
1542

1543
        if self.with_comments and "extra" in df.columns:
9✔
1544
            df.loc[:, "extra_str"] = df.extra.apply(ext_fmt)
8✔
1545
            columns.append("extra_str")
8✔
1546
            # formatters["extra"] = lambda x: " # {0}".format(x) if pd.notnull(x) else 'test'
1547
            # formatters["extra"] = lambda x: ext_fmt(x)
1548

1549
        # only write out the dataframe if it contains data - could be empty
1550
        if len(df) > 0:
9✔
1551
            f.write(
9✔
1552
                df.to_string(
1553
                    col_space=0,
1554
                    formatters=formatters,
1555
                    columns=columns,
1556
                    justify="right",
1557
                    header=False,
1558
                    index=False,
1559
                )
1560
                + "\n"
1561
            )
1562

1563
    def sanity_checks(self, forgive=False):
9✔
1564
        """some basic check for strangeness
1565

1566
        Args:
1567
            forgive (`bool`): flag to forgive (warn) for issues.  Default is False
1568

1569

1570
        Note:
1571
            checks for duplicate names, atleast 1 adjustable parameter
1572
            and at least 1 non-zero-weighted observation
1573

1574
            Not nearly as comprehensive as pestchek
1575

1576
        Example::
1577

1578
            pst = pyemu.Pst("pest.pst")
1579
            pst.sanity_checks()
1580

1581

1582
        """
1583

1584
        dups = self.parameter_data.parnme.value_counts()
9✔
1585
        dups = dups.loc[dups > 1]
9✔
1586
        if dups.shape[0] > 0:
9✔
1587
            if forgive:
8✔
1588
                warnings.warn(
×
1589
                    "duplicate parameter names: {0}".format(",".join(list(dups.index))),
1590
                    PyemuWarning,
1591
                )
1592
            else:
1593
                raise Exception(
8✔
1594
                    "Pst.sanity_check() error: duplicate parameter names: {0}".format(
1595
                        ",".join(list(dups.index))
1596
                    )
1597
                )
1598

1599
        dups = self.observation_data.obsnme.value_counts()
9✔
1600
        dups = dups.loc[dups > 1]
9✔
1601
        if dups.shape[0] > 0:
9✔
1602
            if forgive:
8✔
1603
                warnings.warn(
×
1604
                    "duplicate observation names: {0}".format(
1605
                        ",".join(list(dups.index))
1606
                    ),
1607
                    PyemuWarning,
1608
                )
1609
            else:
1610
                raise Exception(
8✔
1611
                    "Pst.sanity_check() error: duplicate observation names: {0}".format(
1612
                        ",".join(list(dups.index))
1613
                    )
1614
                )
1615

1616
        if self.npar_adj == 0:
9✔
1617
            warnings.warn("no adjustable pars", PyemuWarning)
8✔
1618

1619
        if self.nnz_obs == 0:
9✔
1620
            warnings.warn("no non-zero weight obs", PyemuWarning)
8✔
1621

1622
        if self.tied is not None and len(self.tied) > 0:
9✔
1623
            sadj = set(self.adj_par_names)
8✔
1624
            spar = set(self.par_names)
8✔
1625

1626
            tpar_dict = self.parameter_data.partied.to_dict()
8✔
1627

1628
            for tpar, ptied in tpar_dict.items():
8✔
1629
                if pd.isna(ptied):
8✔
1630
                    continue
8✔
1631
                if tpar == ptied:
8✔
1632
                    if forgive:
8✔
1633
                        warnings.warn(
×
1634
                            "tied parameter '{0}' tied to itself".format(tpar),
1635
                            PyemuWarning,
1636
                        )
1637
                    else:
1638
                        raise Exception(
8✔
1639
                            "Pst.sanity_check() error: tied parameter '{0}' tied to itself".format(
1640
                                tpar
1641
                            )
1642
                        )
1643
                elif ptied not in spar:
8✔
1644
                    if forgive:
8✔
1645
                        warnings.warn(
×
1646
                            "tied parameter '{0}' tied to unknown parameter '{1}'".format(
1647
                                tpar, ptied
1648
                            ),
1649
                            PyemuWarning,
1650
                        )
1651
                    else:
1652
                        raise Exception(
8✔
1653
                            "Pst.sanity_check() error: tied parameter '{0}' tied to unknown parameter '{1}'".format(
1654
                                tpar, ptied
1655
                            )
1656
                        )
1657
                elif ptied not in sadj:
8✔
1658
                    if forgive:
×
1659
                        warnings.warn(
×
1660
                            "tied parameter '{0}' tied to non-adjustable parameter '{1}'".format(
1661
                                tpar, ptied
1662
                            ),
1663
                            PyemuWarning,
1664
                        )
1665
                    else:
1666
                        raise Exception(
×
1667
                            "Pst.sanity_check() error: tied parameter '{0}' tied to non-adjustable parameter '{1}'".format(
1668
                                tpar, ptied
1669
                            )
1670
                        )
1671

1672
        # print("noptmax: {0}".format(self.control_data.noptmax))
1673

1674
    def _write_version2(self, new_filename, use_pst_path=True, pst_rel_path="."):
9✔
1675
        """private method to write a version 2 control file"""
1676
        pst_path = None
8✔
1677
        new_filename = str(new_filename)  # ensure convert to str
8✔
1678
        if use_pst_path:
8✔
1679
            pst_path, _ = Pst._parse_path_agnostic(new_filename)
8✔
1680
        if pst_rel_path == ".":
8✔
1681
            pst_rel_path = ""
8✔
1682

1683
        self.new_filename = new_filename
8✔
1684
        self.rectify_pgroups()
8✔
1685
        self.rectify_pi()
8✔
1686
        self._rectify_parchglim()
8✔
1687
        self._update_control_section()
8✔
1688
        self.sanity_checks()
8✔
1689

1690
        f_out = open(new_filename, "w")
8✔
1691
        if self.with_comments:
8✔
1692
            for line in self.comments.get("initial", []):
×
1693
                f_out.write(line + "\n")
×
1694
        f_out.write("pcf version=2\n")
8✔
1695
        self.control_data.write_keyword(f_out)
8✔
1696

1697
        if self.with_comments:
8✔
1698
            for line in self.comments.get("* singular value decomposition", []):
×
1699
                f_out.write(line)
×
1700
        self.svd_data.write_keyword(f_out)
8✔
1701

1702
        if self.control_data.pestmode.lower().startswith("r"):
8✔
1703
            self.reg_data.write_keyword(f_out)
8✔
1704

1705
        for k, v in self.pestpp_options.items():
8✔
1706
            if isinstance(v, list) or isinstance(v, tuple):
8✔
1707
                v = ",".join([str(vv) for vv in list(v)])
×
1708
            f_out.write("{0:30} {1}\n".format(k, v))
8✔
1709

1710
        # parameter groups
1711
        name = "pargp_data"
8✔
1712
        columns = self.pargp_fieldnames
8✔
1713
        if self.parameter_groups.loc[:, columns].isnull().values.any():
8✔
1714
            # warnings.warn("WARNING: NaNs in {0} dataframe".format(name))
1715
            csv_name = "pst.{0}.nans.csv".format(
8✔
1716
                name.replace(" ", "_").replace("*", "")
1717
            )
1718
            self.parameter_groups.to_csv(csv_name)
8✔
1719
            raise Exception(
8✔
1720
                "NaNs in {0} dataframe, csv written to {1}".format(name, csv_name)
1721
            )
1722
        f_out.write("* parameter groups external\n")
8✔
1723
        pargp_filename = new_filename.lower().replace(".pst", ".{0}.csv".format(name))
8✔
1724
        if pst_path is not None:
8✔
1725
            pargp_filename = os.path.join(pst_path, os.path.split(pargp_filename)[-1])
8✔
1726
        self.parameter_groups.to_csv(pargp_filename, index=False)
8✔
1727
        pargp_filename = os.path.join(pst_rel_path, os.path.split(pargp_filename)[-1])
8✔
1728
        f_out.write("{0}\n".format(pargp_filename))
8✔
1729

1730
        # parameter data
1731
        name = "par_data"
8✔
1732
        columns = self.par_fieldnames
8✔
1733
        if self.parameter_data.loc[:, columns].isnull().values.any():
8✔
1734
            # warnings.warn("WARNING: NaNs in {0} dataframe".format(name))
1735
            csv_name = "pst.{0}.nans.csv".format(
8✔
1736
                name.replace(" ", "_").replace("*", "")
1737
            )
1738
            self.parameter_data.to_csv(csv_name)
8✔
1739
            raise Exception(
8✔
1740
                "NaNs in {0} dataframe, csv written to {1}".format(name, csv_name)
1741
            )
1742
        f_out.write("* parameter data external\n")
8✔
1743
        par_filename = new_filename.lower().replace(".pst", ".{0}.csv".format(name))
8✔
1744
        if pst_path is not None:
8✔
1745
            par_filename = os.path.join(pst_path, os.path.split(par_filename)[-1])
8✔
1746
        self.parameter_data.to_csv(par_filename, index=False)
8✔
1747
        par_filename = os.path.join(pst_rel_path, os.path.split(par_filename)[-1])
8✔
1748
        f_out.write("{0}\n".format(par_filename))
8✔
1749

1750
        # observation data
1751
        name = "obs_data"
8✔
1752
        columns = self.obs_fieldnames
8✔
1753
        if self.observation_data.loc[:, columns].isnull().values.any():
8✔
1754
            # warnings.warn("WARNING: NaNs in {0} dataframe".format(name))
1755
            csv_name = "pst.{0}.nans.csv".format(
8✔
1756
                name.replace(" ", "_").replace("*", "")
1757
            )
1758
            self.observation_data.to_csv(csv_name)
8✔
1759
            raise Exception(
8✔
1760
                "NaNs in {0} dataframe, csv written to {1}".format(name, csv_name)
1761
            )
1762
        f_out.write("* observation data external\n")
8✔
1763
        obs_filename = new_filename.lower().replace(".pst", ".{0}.csv".format(name))
8✔
1764
        if pst_path is not None:
8✔
1765
            obs_filename = os.path.join(pst_path, os.path.split(obs_filename)[-1])
8✔
1766
        self.observation_data.to_csv(obs_filename, index=False)
8✔
1767
        obs_filename = os.path.join(pst_rel_path, os.path.split(obs_filename)[-1])
8✔
1768
        f_out.write("{0}\n".format(obs_filename))
8✔
1769

1770
        f_out.write("* model command line\n")
8✔
1771
        for mc in self.model_command:
8✔
1772
            f_out.write("{0}\n".format(mc))
8✔
1773

1774
        # model input
1775
        name = "tplfile_data"
8✔
1776
        columns = self.model_io_fieldnames
8✔
1777
        if self.model_input_data.loc[:, columns].isnull().values.any():
8✔
1778
            # warnings.warn("WARNING: NaNs in {0} dataframe".format(name))
1779
            csv_name = "pst.{0}.nans.csv".format(
8✔
1780
                name.replace(" ", "_").replace("*", "")
1781
            )
1782
            self.model_input_data.to_csv(csv_name)
8✔
1783
            raise Exception(
8✔
1784
                "NaNs in {0} dataframe, csv written to {1}".format(name, csv_name)
1785
            )
1786
        f_out.write("* model input external\n")
8✔
1787
        io_filename = new_filename.lower().replace(".pst", ".{0}.csv".format(name))
8✔
1788
        if pst_path is not None:
8✔
1789
            io_filename = os.path.join(pst_path, os.path.split(io_filename)[-1])
8✔
1790
        self.model_input_data.to_csv(io_filename, index=False)
8✔
1791
        io_filename = os.path.join(pst_rel_path, os.path.split(io_filename)[-1])
8✔
1792
        f_out.write("{0}\n".format(io_filename))
8✔
1793

1794
        # model output
1795
        name = "insfile_data"
8✔
1796
        columns = self.model_io_fieldnames
8✔
1797
        if self.model_output_data.loc[:, columns].isnull().values.any():
8✔
1798
            # warnings.warn("WARNING: NaNs in {0} dataframe".format(name))
1799
            csv_name = "pst.{0}.nans.csv".format(
8✔
1800
                name.replace(" ", "_").replace("*", "")
1801
            )
1802
            self.model_output_data.to_csv(csv_name)
8✔
1803
            raise Exception(
8✔
1804
                "NaNs in {0} dataframe, csv written to {1}".format(name, csv_name)
1805
            )
1806
        f_out.write("* model output external\n")
8✔
1807
        io_filename = new_filename.lower().replace(".pst", ".{0}.csv".format(name))
8✔
1808
        if pst_path is not None:
8✔
1809
            io_filename = os.path.join(pst_path, os.path.split(io_filename)[-1])
8✔
1810
        self.model_output_data.to_csv(io_filename, index=False)
8✔
1811
        io_filename = os.path.join(pst_rel_path, os.path.split(io_filename)[-1])
8✔
1812
        f_out.write("{0}\n".format(io_filename))
8✔
1813

1814
        # prior info
1815
        if self.prior_information.shape[0] > 0:
8✔
1816
            name = "pi_data"
8✔
1817
            columns = self.prior_fieldnames
8✔
1818
            if self.prior_information.loc[:, columns].isnull().values.any():
8✔
1819
                # warnings.warn("WARNING: NaNs in {0} dataframe".format(name))
1820
                csv_name = "pst.{0}.nans.csv".format(
8✔
1821
                    name.replace(" ", "_").replace("*", "")
1822
                )
1823
                self.prior_information.to_csv(csv_name)
8✔
1824
                raise Exception(
8✔
1825
                    "NaNs in {0} dataframe, csv written to {1}".format(name, csv_name)
1826
                )
1827
            f_out.write("* prior information external\n")
8✔
1828
            pi_filename = new_filename.lower().replace(".pst", ".{0}.csv".format(name))
8✔
1829
            if pst_path is not None:
8✔
1830
                pi_filename = os.path.join(pst_path, os.path.split(pi_filename)[-1])
8✔
1831
            self.prior_information.to_csv(pi_filename, index=False)
8✔
1832
            pi_filename = os.path.join(pst_rel_path, os.path.split(pi_filename)[-1])
8✔
1833
            f_out.write("{0}\n".format(pi_filename))
8✔
1834

1835
        f_out.close()
8✔
1836

1837
    def write(self, new_filename, version=None, check_interface=False):
9✔
1838
        """main entry point to write a pest control file.
1839

1840
        Args:
1841
            new_filename (`str`): name of the new pest control file
1842
            version (`int`): flag for which version of control file to write (must be 1 or 2).
1843
                if None, uses the number of pars to decide: if number of pars iis greater than 10,000,
1844
                version 2 is used.
1845
            check_interface (`bool`): flag to check the control file par and obs names against the
1846
                names found in the template and instruction files.  Default is False
1847

1848

1849

1850
        Example::
1851

1852
            pst = pyemu.Pst("my.pst")
1853
            pst.parrep("my.par")
1854
            pst.write(my_new.pst")
1855
            #write a version 2 control file
1856
            pst.write("my_new_v2.pst",version=2)
1857

1858
        """
1859

1860
        if check_interface:
9✔
1861
            pst_path = os.path.split(new_filename)[0]
8✔
1862
            pst_utils.check_interface(self,pst_path)
8✔
1863

1864
        vstring = "noptmax:{0}, npar_adj:{1}, nnz_obs:{2}".format(
9✔
1865
            self.control_data.noptmax, self.npar_adj, self.nnz_obs
1866
        )
1867
        print(vstring)
9✔
1868

1869
        if version is None:
9✔
1870
            if self.npar > 10000:
9✔
1871
                version = 2
8✔
1872
            else:
1873
                version = 1
9✔
1874

1875
        if version == 1:
9✔
1876
            return self._write_version1(new_filename=new_filename)
9✔
1877
        elif version == 2:
8✔
1878
            return self._write_version2(new_filename=new_filename)
8✔
1879
        else:
1880
            raise Exception(
×
1881
                "Pst.write() error: version must be 1 or 2, not '{0}'".format(version)
1882
            )
1883

1884
    def _rectify_parchglim(self):
9✔
1885
        """private method to just fix the parchglim vs cross zero issue"""
1886
        par = self.parameter_data
9✔
1887
        need_fixing = par.loc[par.parubnd > 0, :].copy()
9✔
1888
        need_fixing = need_fixing.loc[par.parlbnd <= 0, "parnme"]
9✔
1889

1890
        self.parameter_data.loc[need_fixing, "parchglim"] = "relative"
9✔
1891

1892
    def _write_version1(self, new_filename):
9✔
1893
        """private method to write a version 1 pest control file"""
1894
        self.new_filename = new_filename
9✔
1895
        self.rectify_pgroups()
9✔
1896
        self.rectify_pi()
9✔
1897
        self._update_control_section()
9✔
1898
        self._rectify_parchglim()
9✔
1899
        self.sanity_checks()
9✔
1900

1901
        f_out = open(new_filename, "w")
9✔
1902
        if self.with_comments:
9✔
1903
            for line in self.comments.get("initial", []):
8✔
1904
                f_out.write(line + "\n")
×
1905
        f_out.write("pcf\n* control data\n")
9✔
1906
        self.control_data.write(f_out)
9✔
1907

1908
        # for line in self.other_lines:
1909
        #     f_out.write(line)
1910
        if self.with_comments:
9✔
1911
            for line in self.comments.get("* singular value decomposition", []):
8✔
1912
                f_out.write(line)
×
1913
        self.svd_data.write(f_out)
9✔
1914

1915
        # f_out.write("* parameter groups\n")
1916

1917
        # to catch the byte code ugliness in python 3
1918
        pargpnme = self.parameter_groups.loc[:, "pargpnme"].copy()
9✔
1919
        self.parameter_groups.loc[:, "pargpnme"] = self.parameter_groups.pargpnme.apply(
9✔
1920
            self.pargp_format["pargpnme"]
1921
        )
1922

1923
        self._write_df(
9✔
1924
            "* parameter groups",
1925
            f_out,
1926
            self.parameter_groups,
1927
            self.pargp_format,
1928
            self.pargp_fieldnames,
1929
        )
1930
        self.parameter_groups.loc[:, "pargpnme"] = pargpnme
9✔
1931

1932
        self._write_df(
9✔
1933
            "* parameter data",
1934
            f_out,
1935
            self.parameter_data,
1936
            self.par_format,
1937
            self.par_fieldnames,
1938
        )
1939

1940
        if self.tied is not None:
9✔
1941
            self._write_df(
8✔
1942
                "tied parameter data",
1943
                f_out,
1944
                self.tied,
1945
                self.tied_format,
1946
                self.tied_fieldnames,
1947
            )
1948

1949
        f_out.write("* observation groups\n")
9✔
1950
        for group in self.obs_groups:
9✔
1951
            try:
9✔
1952
                group = group.decode()
9✔
1953
            except Exception as e:
9✔
1954
                pass
9✔
1955
            f_out.write(pst_utils.SFMT(str(group)) + "\n")
9✔
1956
        for group in self.prior_groups:
9✔
1957
            try:
9✔
1958
                group = group.decode()
9✔
1959
            except Exception as e:
9✔
1960
                pass
9✔
1961
            f_out.write(pst_utils.SFMT(str(group)) + "\n")
9✔
1962
        self._write_df(
9✔
1963
            "* observation data",
1964
            f_out,
1965
            self.observation_data,
1966
            self.obs_format,
1967
            self.obs_fieldnames,
1968
        )
1969

1970
        f_out.write("* model command line\n")
9✔
1971
        for cline in self.model_command:
9✔
1972
            f_out.write(cline + "\n")
9✔
1973

1974
        name = "tplfile_data"
9✔
1975
        columns = self.model_io_fieldnames
9✔
1976
        if self.model_input_data.loc[:, columns].isnull().values.any():
9✔
1977
            # warnings.warn("WARNING: NaNs in {0} dataframe".format(name))
1978
            csv_name = "pst.{0}.nans.csv".format(
8✔
1979
                name.replace(" ", "_").replace("*", "")
1980
            )
1981
            self.model_input_data.to_csv(csv_name)
8✔
1982
            raise Exception(
8✔
1983
                "NaNs in {0} dataframe, csv written to {1}".format(name, csv_name)
1984
            )
1985
        name = "insfile_data"
9✔
1986
        columns = self.model_io_fieldnames
9✔
1987
        if self.model_output_data.loc[:, columns].isnull().values.any():
9✔
1988
            # warnings.warn("WARNING: NaNs in {0} dataframe".format(name))
1989
            csv_name = "pst.{0}.nans.csv".format(
8✔
1990
                name.replace(" ", "_").replace("*", "")
1991
            )
1992
            self.model_output_data.to_csv(csv_name)
8✔
1993
            raise Exception(
8✔
1994
                "NaNs in {0} dataframe, csv written to {1}".format(name, csv_name)
1995
            )
1996
        f_out.write("* model input/output\n")
9✔
1997
        for tplfle, infle in zip(
9✔
1998
            self.model_input_data.pest_file, self.model_input_data.model_file
1999
        ):
2000
            f_out.write("{0} {1}\n".format(tplfle, infle))
9✔
2001
        for insfle, outfle in zip(
9✔
2002
            self.model_output_data.pest_file, self.model_output_data.model_file
2003
        ):
2004
            f_out.write("{0} {1}\n".format(insfle, outfle))
9✔
2005

2006
        if self.nprior > 0:
9✔
2007
            name = "pi_data"
9✔
2008
            columns = self.prior_fieldnames
9✔
2009
            if self.prior_information.loc[:, columns].isnull().values.any():
9✔
2010
                # warnings.warn("WARNING: NaNs in {0} dataframe".format(name))
2011
                csv_name = "pst.{0}.nans.csv".format(
8✔
2012
                    name.replace(" ", "_").replace("*", "")
2013
                )
2014
                self.prior_information.to_csv(csv_name)
8✔
2015
                raise Exception(
8✔
2016
                    "NaNs in {0} dataframe, csv written to {1}".format(name, csv_name)
2017
                )
2018
            f_out.write("* prior information\n")
9✔
2019
            # self.prior_information.index = self.prior_information.pop("pilbl")
2020
            max_eq_len = self.prior_information.equation.apply(lambda x: len(x)).max()
9✔
2021
            eq_fmt_str = " {0:<" + str(max_eq_len) + "s} "
9✔
2022
            eq_fmt_func = lambda x: eq_fmt_str.format(x)
9✔
2023
            #  17/9/2016 - had to go with a custom writer loop b/c pandas doesn't want to
2024
            # output strings longer than 100, even with display.max_colwidth
2025
            # f_out.write(self.prior_information.to_string(col_space=0,
2026
            #                                  columns=self.prior_fieldnames,
2027
            #                                  formatters=pi_formatters,
2028
            #                                  justify="right",
2029
            #                                  header=False,
2030
            #                                 index=False) + '\n')
2031
            # self.prior_information["pilbl"] = self.prior_information.index
2032
            # for idx,row in self.prior_information.iterrows():
2033
            #     f_out.write(pst_utils.SFMT(row["pilbl"]))
2034
            #     f_out.write(eq_fmt_func(row["equation"]))
2035
            #     f_out.write(pst_utils.FFMT(row["weight"]))
2036
            #     f_out.write(pst_utils.SFMT(row["obgnme"]) + '\n')
2037
            for _, row in self.prior_information.iterrows():
9✔
2038
                f_out.write(pst_utils.SFMT(row["pilbl"]))
9✔
2039
                f_out.write(eq_fmt_func(row["equation"]))
9✔
2040
                f_out.write(pst_utils.FFMT(row["weight"]))
9✔
2041
                f_out.write(pst_utils.SFMT(row["obgnme"]))
9✔
2042
                if self.with_comments and "extra" in row:
9✔
2043
                    f_out.write(" # {0}".format(row["extra"]))
×
2044
                f_out.write("\n")
9✔
2045

2046
        if self.control_data.pestmode.startswith("regul"):
9✔
2047
            # f_out.write("* regularisation\n")
2048
            # if update_regul or len(self.regul_lines) == 0:
2049
            #    f_out.write(self.regul_section)
2050
            # else:
2051
            #    [f_out.write(line) for line in self.regul_lines]
2052
            self.reg_data.write(f_out)
9✔
2053

2054
        for line in self.other_lines:
9✔
2055
            f_out.write(line + "\n")
×
2056

2057
        for key, value in self.pestpp_options.items():
9✔
2058
            if isinstance(value, list) or isinstance(value, tuple):
9✔
2059
                value = ",".join([str(v) for v in list(value)])
×
2060
            f_out.write("++{0}({1})\n".format(str(key), str(value)))
9✔
2061

2062
        if self.with_comments:
9✔
2063
            for line in self.comments.get("final", []):
8✔
2064
                f_out.write(line + "\n")
×
2065

2066
        f_out.close()
9✔
2067

2068
    def bounds_report(self, iterations=None):
9✔
2069
        """report how many parameters are at bounds. If ensemble, the base enbsemble member is evaluated
2070

2071
        Args:
2072
            iterations ([`int`]): a list of iterations for which a bounds report is requested
2073
                If None, all iterations for which `par` files are located are reported. Default
2074
                is None
2075

2076
        Returns:
2077
            `df`: a pandas DataFrame object with rows being parameter groups and columns
2078
                <iter>_num_at_ub, <iter>_num_at_lb, and <iter>_total_at_bounds
2079
                row 0 is total at bounds, subsequent rows correspond with groups
2080

2081
        Example:
2082
            pst = pyemu.Pst("my.pst")
2083
            df = pst.bound_report(iterations=[0,2,3])
2084

2085
        """
2086
        # sort out which files are parameter files and parse pstroot from pst directory
2087
        pstroot = self.filename
×
2088
        if pstroot.lower().endswith(".pst"):
×
2089
            pstroot = pstroot[:-4]
×
2090
        pstdir = os.path.dirname(pstroot)
×
2091
        if len(pstdir) == 0:
×
2092
            pstdir = "."
×
2093
        pstroot = os.path.basename(pstroot)
×
2094

2095
        # find all the par files
2096
        parfiles = glob.glob(os.path.join(pstdir, "{}*.par".format(pstroot)))
×
2097

2098
        # exception if no par files found
2099
        if len(parfiles) == 0:
×
2100
            raise Exception(
×
2101
                "no par files with root {} in directory {}".format(pstdir, pstroot)
2102
            )
2103

2104
        is_ies = any(["base" in i.lower() for i in parfiles])
×
2105
        # decide which iterations we care about
2106
        if is_ies:
×
2107
            iters = [
×
2108
                os.path.basename(cf).replace(pstroot, "").split(".")[1]
2109
                for cf in parfiles
2110
                if "base" in cf.lower()
2111
            ]
2112
            iters = [int(i) for i in iters if i != "base"]
×
2113
            parfiles = [i for i in parfiles if "base" in i]
×
2114
        else:
2115
            iters = [
×
2116
                os.path.basename(cf).replace(pstroot, "").split(".")[1]
2117
                for cf in parfiles
2118
                if "base" not in cf.lower()
2119
            ]
2120
            iters = [int(i) for i in iters if i != "par"]
×
2121
            parfiles = [i for i in parfiles if "base" not in i]
×
2122

2123
        if iterations is None:
×
2124
            iterations = iters
×
2125

2126
        if isinstance(iterations, tuple):
×
2127
            iterations = list(iterations)
×
2128

2129
        if not isinstance(iterations, list):
×
2130
            iterations = [iterations]
×
2131

2132
        # sort the iterations to go through them in order
2133
        iterations.sort()
×
2134

2135
        # set up a DataFrame with bounds and into which to put the par values
2136
        allpars = self.parameter_data[["parlbnd", "parubnd", "pargp"]].copy()
×
2137

2138
        # loop over iterations and calculate which are at upper and lower bounds
2139
        for citer in iterations:
×
2140
            try:
×
2141
                tmp = pd.read_csv(
×
2142
                    os.path.join(pstdir, "{}.{}.base.par".format(pstroot, citer)),
2143
                    skiprows=1,
2144
                    index_col=0,
2145
                    usecols=[0, 1],
2146
                    delim_whitespace=True,
2147
                    header=None,
2148
                    low_memory = False
2149
                )
2150
            except FileNotFoundError:
×
2151
                raise Exception(
×
2152
                    "iteration {} does not have a paramter file associated with it in {}".format(
2153
                        citer, pstdir
2154
                    )
2155
                )
2156
            tmp.columns = ["pars_iter_{}".format(citer)]
×
2157
            allpars = allpars.merge(tmp, left_index=True, right_index=True)
×
2158
            allpars["at_upper_bound_{}".format(citer)] = (
×
2159
                allpars["pars_iter_{}".format(citer)] >= allpars["parubnd"]
2160
            )
2161
            allpars["at_lower_bound_{}".format(citer)] = (
×
2162
                allpars["pars_iter_{}".format(citer)] <= allpars["parlbnd"]
2163
            )
2164

2165
        # sum up by groups
2166
        df = (
×
2167
            allpars.groupby("pargp")
2168
            .sum()[[i for i in allpars.columns if i.startswith("at_")]]
2169
            .astype(int)
2170
        )
2171

2172
        # add the total
2173
        df.loc["total"] = df.sum()
×
2174

2175
        # sum up upper and lower bounds
2176
        cols = []
×
2177
        for citer in iterations:
×
2178
            df["at_either_bound_{}".format(citer)] = (
×
2179
                df["at_upper_bound_{}".format(citer)]
2180
                + df["at_lower_bound_{}".format(citer)]
2181
            )
2182
            cols.extend(
×
2183
                [
2184
                    "at_either_bound_{}".format(citer),
2185
                    "at_lower_bound_{}".format(citer),
2186
                    "at_upper_bound_{}".format(citer),
2187
                ]
2188
            )
2189

2190
        # reorder by iterations and return
2191
        return df[cols]
×
2192

2193
        # loop over the iterations and count the pars at bounds
2194

2195
    def get(self, par_names=None, obs_names=None):
9✔
2196
        """get a new pst object with subset of parameters and/or observations
2197

2198
        Args:
2199
            par_names ([`str`]): a list of parameter names to have in the new Pst instance.
2200
                If None, all parameters are in the new Pst instance. Default
2201
                is None
2202
            obs_names ([`str`]): a list of observation names to have in the new Pst instance.
2203
                If None, all observations are in teh new Pst instance. Default
2204
                is None
2205

2206
        Returns:
2207
            `Pst`: a new Pst instance
2208

2209
        Note:
2210
            passing `par_names` as `None` and `obs_names` as `None` effectively
2211
            generates a copy of the current `Pst`
2212

2213
            Does not modify model i/o files - this is just a method for performing pyemu operations
2214

2215
        Example::
2216

2217
            pst = pyemu.Pst("pest.pst")
2218
            new_pst = pst.get(pst.adj_par_names[0],pst.obs_names[:10])
2219

2220
        """
2221

2222
        # if par_names is None and obs_names is None:
2223
        #    return copy.deepcopy(self)
2224
        if par_names is None:
9✔
2225
            par_names = self.parameter_data.parnme
9✔
2226
        if obs_names is None:
9✔
2227
            obs_names = self.observation_data.obsnme
9✔
2228

2229
        new_par = self.parameter_data.copy()
9✔
2230
        if par_names is not None:
9✔
2231
            new_par.index = new_par.parnme
9✔
2232
            new_par = new_par.loc[par_names, :]
9✔
2233
        new_obs = self.observation_data.copy()
9✔
2234
        new_res = None
9✔
2235

2236
        if obs_names is not None:
9✔
2237
            new_obs.index = new_obs.obsnme
9✔
2238
            new_obs = new_obs.loc[obs_names]
9✔
2239
            if self.__res is not None:
9✔
2240
                new_res = copy.deepcopy(self.res)
9✔
2241
                new_res.index = new_res.name
9✔
2242
                new_res = new_res.loc[obs_names, :]
9✔
2243

2244
        self.rectify_pgroups()
9✔
2245
        new_pargp = self.parameter_groups.copy()
9✔
2246
        new_pargp.index = new_pargp.pargpnme.apply(str.strip)
9✔
2247
        new_pargp_names = new_par.pargp.value_counts().index
9✔
2248
        new_pargp.reindex(new_pargp_names)
9✔
2249

2250
        new_pst = Pst(self.filename, resfile=self.resfile, load=False)
9✔
2251
        new_pst.parameter_data = new_par
9✔
2252
        new_pst.observation_data = new_obs
9✔
2253
        new_pst.parameter_groups = new_pargp
9✔
2254
        new_pst.__res = new_res
9✔
2255
        new_pst.prior_information = self.prior_information
9✔
2256
        new_pst.rectify_pi()
9✔
2257
        new_pst.control_data = self.control_data.copy()
9✔
2258

2259
        new_pst.model_command = self.model_command
9✔
2260
        new_pst.model_input_data = self.model_input_data.copy()
9✔
2261
        new_pst.model_output_data = self.model_output_data.copy()
9✔
2262
        if self.tied is not None:
9✔
2263
            warnings.warn(
×
2264
                "Pst.get() not checking for tied parameter "
2265
                + "compatibility in new Pst instance",
2266
                PyemuWarning,
2267
            )
2268
            # new_pst.tied = self.tied.copy()
2269
        new_pst.other_lines = self.other_lines
9✔
2270
        new_pst.pestpp_options = self.pestpp_options
9✔
2271
        new_pst.regul_lines = self.regul_lines
9✔
2272

2273
        return new_pst
9✔
2274

2275
    def parrep(
9✔
2276
        self,
2277
        parfile=None,
2278
        enforce_bounds=True,
2279
        real_name=None,
2280
        noptmax=0,
2281
        binary_ens_file=False,
2282
    ):
2283
        """replicates the pest parrep util. replaces the parval1 field in the
2284
            parameter data section dataframe with values in a PEST parameter file
2285
            or a single realization from an ensemble parameter csv file
2286

2287
        Args:
2288
            parfile (`str`, optional): parameter file to use.  If None, try to find and use
2289
                a parameter file that corresponds to the case name.
2290
                If parfile has extension '.par' a single realization parameter file is used
2291
                If parfile has extention '.csv' an ensemble parameter file is used which invokes real_name
2292
                If parfile has extention '.jcb' a binary ensemble parameter file is used which invokes real_name
2293
                Default is None
2294
            enforce_bounds (`bool`, optional): flag to enforce parameter bounds after parameter values are updated.
2295
                This is useful because PEST and PEST++ round the parameter values in the
2296
                par file, which may cause slight bound violations.  Default is `True`
2297
            real_name (`str` or `int`, optional): name of the ensemble realization to use for updating the
2298
                parval1 value in the parameter data section dataframe. If None, try using "base". If "base"
2299
                not present, use the real_name with smallest index number.
2300
                Ignored if parfile is of the PEST parameter file format (e.g. not en ensemble)
2301
            noptmax (`int`, optional): Value with which to update the pst.control_data.noptmax value
2302
                Default is 0.
2303
            binary_ens_file (`bool`): If True, use binary format to load ensemble file, else assume it's a CSV file
2304
        Example::
2305

2306
            pst = pyemu.Pst("pest.pst")
2307
            pst.parrep("pest.1.base.par")
2308
            pst.control_data.noptmax = 0
2309
            pst.write("pest_1.pst")
2310

2311
        """
2312

2313
        if parfile is None:
9✔
2314
            parfile = self.filename.replace(".pst", ".par")
×
2315
        # first handle the case of a single parameter realization in a PAR file
2316
        if parfile.lower().endswith(".par"):
9✔
2317
            print("Updating parameter values from {0}".format(parfile))
9✔
2318
            par_df = pst_utils.read_parfile(parfile)
9✔
2319
            self.parameter_data.index = self.parameter_data.parnme
9✔
2320
            par_df.index = par_df.parnme
9✔
2321
            self.parameter_data.parval1 = par_df.parval1
9✔
2322
            self.parameter_data.scale = par_df.scale
9✔
2323
            self.parameter_data.offset = par_df.offset
9✔
2324

2325
        # next handle ensemble case
2326
        if parfile.lower()[-4:] in [".jcb", ".bin"]:
9✔
2327
            binary_ens_file = True
8✔
2328
        if parfile.lower()[-4:] in [".jcb", ".bin", ".csv"]:
9✔
2329
            if parfile.lower().endswith(".csv"):
8✔
2330
                parens = pd.read_csv(parfile, index_col=0,low_memory=False)
8✔
2331
            if binary_ens_file == True:
8✔
2332
                parens = pyemu.ParameterEnsemble.from_binary(
8✔
2333
                    pst=self, filename=parfile
2334
                )._df
2335
            # cast the parens.index to string to be sure indexing is cool
2336
            parens.index = [str(i).lower() for i in parens.index]
8✔
2337
            # handle None case (potentially) for real_name
2338
            if real_name is None:
8✔
2339
                if "base" in parens.index:
8✔
2340
                    real_name = "base"
8✔
2341
                else:
2342
                    real_name = str(min([int(i) for i in parens.index]))
8✔
2343
            # cast the real_name to string to be sure indexing is cool
2344
            real_name = str(real_name)
8✔
2345

2346
            # now update with a little pandas trickery
2347
            print(
8✔
2348
                "updating parval1 using realization:'{}' from ensemble file {}".format(
2349
                    real_name, parfile
2350
                )
2351
            )
2352
            self.parameter_data.parval1 = parens.loc[real_name].T.loc[
8✔
2353
                self.parameter_data.parnme
2354
            ]
2355

2356
        if enforce_bounds:
9✔
2357
            par = self.parameter_data
9✔
2358
            idx = par.loc[par.parval1 > par.parubnd, "parnme"]
9✔
2359
            par.loc[idx, "parval1"] = par.loc[idx, "parubnd"]
9✔
2360
            idx = par.loc[par.parval1 < par.parlbnd, "parnme"]
9✔
2361
            par.loc[idx, "parval1"] = par.loc[idx, "parlbnd"]
9✔
2362
        print("parrep: updating noptmax to {}".format(int(noptmax)))
9✔
2363
        self.control_data.noptmax = int(noptmax)
9✔
2364

2365
    def adjust_weights_discrepancy(
9✔
2366
        self, resfile=None, original_ceiling=True, bygroups=False
2367
    ):
2368
        """adjusts the weights of each non-zero weight observation based
2369
        on the residual in the pest residual file so each observations contribution
2370
        to phi is 1.0 (e.g. Mozorov's discrepancy principal)
2371

2372
        Args:
2373
            resfile (`str`): residual file name.  If None, try to use a residual file
2374
                with the Pst case name.  Default is None
2375
            original_ceiling (`bool`): flag to keep weights from increasing - this is
2376
                generally a good idea. Default is True
2377
            bygroups (`bool`): flag to adjust weights by groups. If False, the weight
2378
                of each non-zero weighted observation is adjusted individually. If True,
2379
                intergroup weighting is preserved (the contribution to each group is used)
2380
                but this may result in some strangeness if some observations in a group have
2381
                a really low phi already.
2382

2383
        Example::
2384

2385
            pst = pyemu.Pst("my.pst")
2386
            print(pst.phi) #assumes "my.res" is colocated with "my.pst"
2387
            pst.adjust_weights_discrepancy()
2388
            print(pst.phi) # phi should equal number of non-zero observations
2389

2390
        """
2391
        if resfile is not None:
9✔
2392
            self.resfile = resfile
×
2393
            self.__res = None
×
2394
        if bygroups:
9✔
2395
            phi_comps = self.phi_components
8✔
2396
            self._adjust_weights_by_phi_components(phi_comps, original_ceiling)
8✔
2397
        else:
2398
            names = self.nnz_obs_names
9✔
2399
            obs = self.observation_data.loc[names, :]
9✔
2400
            # "Phi should equal nnz - nnzobs that satisfy inequ"
2401
            res = self.res.loc[names, :].residual
9✔
2402
            og = obs.obgnme
9✔
2403
            res.loc[
9✔
2404
                (og.str.startswith(self.get_constraint_tags('gt'))) &
2405
                (res <= 0)] = 0
2406
            res.loc[
9✔
2407
                (og.str.startswith(self.get_constraint_tags('lt'))) &
2408
                (res >= 0)] = 0
2409
            swr = (res * obs.weight) ** 2
9✔
2410
            factors = (1.0 / swr)**0.5
9✔
2411
            if original_ceiling:
9✔
2412
                factors = factors.apply(lambda x: 1.0 if x > 1.0 else x)
1✔
2413

2414
            w = self.observation_data.weight
9✔
2415
            w.loc[names] *= factors.values
9✔
2416

2417
    def _adjust_weights_by_phi_components(self, components, original_ceiling):
9✔
2418
        """private method that resets the weights of observations by group to account for
2419
        residual phi components.
2420

2421
        Args:
2422
            components (`dict`): a dictionary of obs group:phi contribution pairs
2423
            original_ceiling (`bool`): flag to keep weights from increasing.
2424

2425
        """
2426
        obs = self.observation_data
8✔
2427
        nz_groups = obs.groupby(obs["weight"].map(lambda x: x == 0)).groups
8✔
2428
        ogroups = obs.groupby("obgnme").groups
8✔
2429
        for ogroup, idxs in ogroups.items():
8✔
2430
            if (
8✔
2431
                self.control_data.pestmode.startswith("regul")
2432
                and "regul" in ogroup.lower()
2433
            ):
2434
                continue
×
2435
            og_phi = components[ogroup]
8✔
2436
            nz_groups = (
8✔
2437
                obs.loc[idxs, :]
2438
                .groupby(obs.loc[idxs, "weight"].map(lambda x: x == 0))
2439
                .groups
2440
            )
2441
            og_nzobs = 0
8✔
2442
            if False in nz_groups.keys():
8✔
2443
                og_nzobs = len(nz_groups[False])
8✔
2444
            if og_nzobs == 0 and og_phi > 0:
8✔
2445
                raise Exception(
×
2446
                    "Pst.adjust_weights_by_phi_components():"
2447
                    " no obs with nonzero weight,"
2448
                    + " but phi > 0 for group:"
2449
                    + str(ogroup)
2450
                )
2451
            if og_phi > 0:
8✔
2452
                factor = np.sqrt(float(og_nzobs) / float(og_phi))
8✔
2453
                if original_ceiling:
8✔
2454
                    factor = min(factor, 1.0)
8✔
2455
                obs.loc[idxs, "weight"] = obs.weight[idxs] * factor
8✔
2456
        self.observation_data = obs
8✔
2457

2458
    def __reset_weights(self, target_phis, res_idxs, obs_idxs):
9✔
2459
        """private method to reset weights based on target phi values
2460
        for each group.  This method should not be called directly
2461

2462
        Args:
2463
            target_phis (`dict`): target phi contribution for groups to reweight
2464
            res_idxs (`dict`): the index positions of each group of interest
2465
                in the res dataframe
2466
            obs_idxs (`dict`): the index positions of each group of interest
2467
                in the observation data dataframe
2468

2469
        """
2470

2471
        obs = self.observation_data
9✔
2472
        res = self.res
9✔
2473
        for item in target_phis.keys():
9✔
2474
            if item not in res_idxs.keys():
9✔
2475
                raise Exception(
×
2476
                    "Pst.__reset_weights(): "
2477
                    + str(item)
2478
                    + " not in residual group indices"
2479
                )
2480
            if item not in obs_idxs.keys():
9✔
2481
                raise Exception(
×
2482
                    "Pst.__reset_weights(): "
2483
                    + str(item)
2484
                    + " not in observation group indices"
2485
                )
2486
            # actual_phi = ((self.res.loc[res_idxs[item], "residual"] *
2487
            #               self.observation_data.loc
2488
            #               [obs_idxs[item], "weight"])**2).sum()
2489
            tmpobs = obs.loc[obs_idxs[item]]
9✔
2490
            resid = (
9✔
2491
                    tmpobs.obsval
2492
                    - res.loc[res_idxs[item], "modelled"]
2493
            ).loc[tmpobs.index]
2494
            og = tmpobs.obgnme
9✔
2495
            resid.loc[
9✔
2496
                (og.str.startswith(self.get_constraint_tags('gt'))) &
2497
                (resid <= 0)] = 0
2498
            resid.loc[
9✔
2499
                (og.str.startswith(self.get_constraint_tags('lt'))) &
2500
                (resid >= 0)] = 0
2501

2502
            actual_phi = np.sum(
9✔
2503
                (
2504
                    resid
2505
                    * obs.loc[obs_idxs[item], "weight"]
2506
                )
2507
                ** 2
2508
            )
2509
            if actual_phi > 0.0:
9✔
2510
                weight_mult = np.sqrt(target_phis[item] / actual_phi)
9✔
2511
                obs.loc[obs_idxs[item], "weight"] *= weight_mult
9✔
2512
            else:
2513
                (
×
2514
                    "Pst.__reset_weights() warning: phi group {0} has zero phi, skipping...".format(
2515
                        item
2516
                    )
2517
                )
2518

2519
    def _adjust_weights_by_list(self, obslist, weight):
9✔
2520
        """a private method to reset the weight for a list of observation names.  Supports the
2521
        data worth analyses in pyemu.Schur class.  This method only adjusts
2522
        observation weights in the current weight is nonzero.  User beware!
2523

2524
        Args:
2525
            obslist ([`str`]): list of observation names
2526
            weight (`float`): new weight to assign
2527
        """
2528

2529
        obs = self.observation_data
9✔
2530
        if not isinstance(obslist, list):
9✔
2531
            obslist = [obslist]
×
2532
        obslist = set([str(i).lower() for i in obslist])
9✔
2533
        # groups = obs.groupby([lambda x:x in obslist,
2534
        #                     obs.weight.apply(lambda x:x==0.0)]).groups
2535
        # if (True,True) in groups:
2536
        #    obs.loc[groups[True,True],"weight"] = weight
2537
        reset_names = obs.loc[
9✔
2538
            obs.apply(lambda x: x.obsnme in obslist and x.weight == 0, axis=1), "obsnme"
2539
        ]
2540
        if len(reset_names) > 0:
9✔
2541
            obs.loc[reset_names, "weight"] = weight
9✔
2542

2543
    def adjust_weights(self, obs_dict=None, obsgrp_dict=None):
9✔
2544
        """reset the weights of observations or observation groups to contribute a specified
2545
        amount to the composite objective function
2546

2547
        Args:
2548
            obs_dict (`dict`, optional): dictionary of observation name,new contribution pairs
2549
            obsgrp_dict (`dict`, optional): dictionary of obs group name,contribution pairs
2550

2551
        Notes:
2552
            If a group is assigned a contribution of 0, all observations in that group will be assigned
2553
            zero weight.
2554

2555
            If a group is assigned a nonzero contribution AND all observations in that group start
2556
            with zero weight, the observations will be assigned weight of 1.0 to allow balancing.
2557

2558
            If groups obsgrp_dict is not passed, all nonzero
2559
            
2560

2561
        Example::
2562

2563
            pst = pyemu.Pst("my.pst")
2564

2565
            # adjust a single observation
2566
            pst.adjust_weights(obs_dict={"obs1":10})
2567

2568
            # adjust a single observation group
2569
            pst.adjust_weights(obsgrp_dict={"group1":100.0})
2570

2571
            # make all non-zero weighted groups have a contribution of 100.0
2572
            balanced_groups = {grp:100 for grp in pst.nnz_obs_groups}
2573
            pst.adjust_weights(obsgrp_dict=balanced_groups)
2574

2575
        """
2576
        if (obsgrp_dict is not None) and (obs_dict is not None):
9✔
2577
            
2578
            raise Exception(
×
2579
                "Pst.asjust_weights(): "
2580
                + "Both obsgrp_dict and obs_dict passed "
2581
                + "Must choose one or the other"
2582
            )
2583

2584
        self.observation_data.index = self.observation_data.obsnme
9✔
2585
        self.res.index = self.res.name
9✔
2586

2587
        if obsgrp_dict is not None:
9✔
2588
            obs = self.observation_data
9✔
2589
            # first zero-weight all obs in groups specified to have 0 contrib to phi
2590
            original_groups = list(obsgrp_dict.keys())
9✔
2591
            for grp in original_groups:
9✔
2592
                if obsgrp_dict[grp] == 0:
9✔
2593
                    obs.loc[obs.obgnme == grp, "weight"] = 0.
8✔
2594
                    del obsgrp_dict[grp]
8✔
2595
                # reset groups with all zero weights
2596
                elif obs.loc[obs.obgnme == grp, "weight"].sum() == 0:
9✔
2597
                    obs.loc[obs.obgnme==grp, "weight"] = 1.
8✔
2598
            self.res.loc[obs.index, 'group'] = obs.obgnme.values
9✔
2599
            self.res.loc[obs.index, 'weight'] = obs.weight.values 
9✔
2600
            res_groups = self.res.groupby("group").groups
9✔
2601
            obs_groups = self.observation_data.groupby("obgnme").groups
9✔
2602
            self.__reset_weights(obsgrp_dict, res_groups, obs_groups)
9✔
2603
        if obs_dict is not None:
9✔
2604
            # reset obs with zero weight
2605
            obs = self.observation_data
9✔
2606
            for oname in obs_dict.keys():
9✔
2607
                if obs.loc[oname, "weight"] == 0.0:
9✔
2608
                    obs.loc[oname, "weight"] = 1.0
8✔
2609

2610
            # res_groups = self.res.groupby("name").groups
2611
            res_groups = self.res.groupby(self.res.index).groups
9✔
2612
            # obs_groups = self.observation_data.groupby("obsnme").groups
2613
            obs_groups = self.observation_data.groupby(
9✔
2614
                self.observation_data.index
2615
            ).groups
2616
            self.__reset_weights(obs_dict, res_groups, obs_groups)
9✔
2617

2618
    def proportional_weights(self, fraction_stdev=1.0, wmax=100.0, leave_zero=True):
9✔
2619
        """setup  weights inversely proportional to the observation value
2620

2621
        Args:
2622
            fraction_stdev (`float`, optional): the fraction portion of the observation
2623
                val to treat as the standard deviation.  set to 1.0 for
2624
                inversely proportional.  Default is 1.0
2625
            wmax (`float`, optional): maximum weight to allow.  Default is 100.0
2626

2627
            leave_zero (`bool`, optional): flag to leave existing zero weights.
2628
                Default is True
2629

2630
        Example::
2631

2632
            pst = pyemu.Pst("pest.pst")
2633
            # set the weights of the observations to 20% of the observed value
2634
            pst.proportional_weights(fraction_stdev=0.2,wmax=10)
2635
            pst.write("pest_propo.pst")
2636

2637
        """
2638
        new_weights = []
1✔
2639
        for oval, ow in zip(self.observation_data.obsval, self.observation_data.weight):
1✔
2640
            if leave_zero and ow == 0.0:
1✔
2641
                ow = 0.0
1✔
2642
            elif oval == 0.0:
1✔
2643
                ow = wmax
×
2644
            else:
2645
                nw = 1.0 / (np.abs(oval) * fraction_stdev)
1✔
2646
                ow = min(wmax, nw)
1✔
2647
            new_weights.append(ow)
1✔
2648
        self.observation_data.weight = new_weights
1✔
2649

2650
    def calculate_pertubations(self):
9✔
2651
        """experimental method to calculate finite difference parameter
2652
        pertubations.
2653

2654
        Note:
2655

2656
            The pertubation values are added to the
2657
            `Pst.parameter_data` attribute - user beware!
2658

2659
        """
2660
        self.build_increments()
8✔
2661
        self.parameter_data.loc[:, "pertubation"] = (
8✔
2662
            self.parameter_data.parval1 + self.parameter_data.increment
2663
        )
2664

2665
        self.parameter_data.loc[:, "out_forward"] = (
8✔
2666
            self.parameter_data.loc[:, "pertubation"]
2667
            > self.parameter_data.loc[:, "parubnd"]
2668
        )
2669

2670
        out_forward = self.parameter_data.groupby("out_forward").groups
8✔
2671
        if True in out_forward:
8✔
2672
            self.parameter_data.loc[out_forward[True], "pertubation"] = (
8✔
2673
                self.parameter_data.loc[out_forward[True], "parval1"]
2674
                - self.parameter_data.loc[out_forward[True], "increment"]
2675
            )
2676

2677
            self.parameter_data.loc[:, "out_back"] = (
8✔
2678
                self.parameter_data.loc[:, "pertubation"]
2679
                < self.parameter_data.loc[:, "parlbnd"]
2680
            )
2681
            out_back = self.parameter_data.groupby("out_back").groups
8✔
2682
            if True in out_back:
8✔
2683
                still_out = out_back[True]
×
2684
                print(self.parameter_data.loc[still_out, :], flush=True)
×
2685

2686
                raise Exception(
×
2687
                    "Pst.calculate_pertubations(): "
2688
                    + "can't calc pertubations for the following "
2689
                    + "Parameters {0}".format(",".join(still_out))
2690
                )
2691

2692
    def build_increments(self):
9✔
2693
        """experimental method to calculate parameter increments for use
2694
        in the finite difference pertubation calculations
2695

2696
        Note:
2697
            user beware!
2698

2699
        """
2700
        self.enforce_bounds()
8✔
2701
        self.add_transform_columns()
8✔
2702
        par_groups = self.parameter_data.groupby("pargp").groups
8✔
2703
        inctype = self.parameter_groups.groupby("inctyp").groups
8✔
2704
        for itype, inc_groups in inctype.items():
8✔
2705
            pnames = []
8✔
2706
            for group in inc_groups:
8✔
2707
                pnames.extend(par_groups[group])
8✔
2708
                derinc = self.parameter_groups.loc[group, "derinc"]
8✔
2709
                self.parameter_data.loc[par_groups[group], "derinc"] = derinc
8✔
2710
            if itype == "absolute":
8✔
2711
                self.parameter_data.loc[pnames, "increment"] = self.parameter_data.loc[
8✔
2712
                    pnames, "derinc"
2713
                ]
2714
            elif itype == "relative":
8✔
2715
                self.parameter_data.loc[pnames, "increment"] = (
8✔
2716
                    self.parameter_data.loc[pnames, "derinc"]
2717
                    * self.parameter_data.loc[pnames, "parval1"]
2718
                )
2719
            elif itype == "rel_to_max":
8✔
2720
                mx = self.parameter_data.loc[pnames, "parval1"].max()
8✔
2721
                self.parameter_data.loc[pnames, "increment"] = (
8✔
2722
                    self.parameter_data.loc[pnames, "derinc"] * mx
2723
                )
2724
            else:
2725
                raise Exception(
×
2726
                    "Pst.get_derivative_increments(): "
2727
                    + "unrecognized increment type:{0}".format(itype)
2728
                )
2729

2730
        # account for fixed pars
2731
        isfixed = self.parameter_data.partrans == "fixed"
8✔
2732
        self.parameter_data.loc[isfixed, "increment"] = self.parameter_data.loc[
8✔
2733
            isfixed, "parval1"
2734
        ]
2735

2736
    def add_transform_columns(self):
9✔
2737
        """add transformed values to the `Pst.parameter_data` attribute
2738

2739
        Note:
2740
            adds `parval1_trans`, `parlbnd_trans` and `parubnd_trans` to
2741
            `Pst.parameter_data`
2742

2743

2744
        Example::
2745

2746
            pst = pyemu.Pst("pest.pst")
2747
            pst.add_transform_columns()
2748
            print(pst.parameter_data.parval1_trans
2749

2750

2751
        """
2752
        for col in ["parval1", "parlbnd", "parubnd", "increment"]:
9✔
2753
            if col not in self.parameter_data.columns:
9✔
2754
                continue
9✔
2755
            self.parameter_data.loc[:, col + "_trans"] = (
9✔
2756
                self.parameter_data.loc[:, col] * self.parameter_data.scale
2757
            ) + self.parameter_data.offset
2758
            # isnotfixed = self.parameter_data.partrans != "fixed"
2759
            islog = self.parameter_data.partrans == "log"
9✔
2760
            self.parameter_data.loc[islog, col + "_trans"] = \
9✔
2761
                self.parameter_data.loc[islog, col + "_trans"].apply(np.log10)
2762

2763
    def enforce_bounds(self):
9✔
2764
        """enforce bounds violation
2765

2766
        Note:
2767
            cheap enforcement of simply bringing violators back in bounds
2768

2769

2770
        Example::
2771

2772
            pst = pyemu.Pst("pest.pst")
2773
            pst.parrep("random.par")
2774
            pst.enforce_bounds()
2775
            pst.write("pest_rando.pst")
2776

2777

2778
        """
2779
        too_big = (
8✔
2780
            self.parameter_data.loc[:, "parval1"]
2781
            > self.parameter_data.loc[:, "parubnd"]
2782
        )
2783
        self.parameter_data.loc[too_big, "parval1"] = self.parameter_data.loc[
8✔
2784
            too_big, "parubnd"
2785
        ]
2786

2787
        too_small = (
8✔
2788
            self.parameter_data.loc[:, "parval1"]
2789
            < self.parameter_data.loc[:, "parlbnd"]
2790
        )
2791
        self.parameter_data.loc[too_small, "parval1"] = self.parameter_data.loc[
8✔
2792
            too_small, "parlbnd"
2793
        ]
2794

2795
    @classmethod
9✔
2796
    def from_io_files(
9✔
2797
        cls, tpl_files, in_files, ins_files, out_files, pst_filename=None, pst_path=None
2798
    ):
2799
        """create a Pst instance from model interface files.
2800

2801
        Args:
2802
            tpl_files ([`str`]): list of template file names
2803
            in_files ([`str`]): list of model input file names (pairs with template files)
2804
            ins_files ([`str`]): list of instruction file names
2805
            out_files ([`str`]): list of model output file names (pairs with instruction files)
2806
            pst_filename (`str`): name of control file to write.  If None, no file is written.
2807
                Default is None
2808
            pst_path ('str'): the path from the control file to the IO files.  For example, if the
2809
                control will be in the same directory as the IO files, then `pst_path` should be '.'.
2810
                Default is None, which doesnt do any path manipulation on the I/O file names
2811

2812

2813
        Returns:
2814
            `Pst`: new control file instance with parameter and observation names
2815
            found in `tpl_files` and `ins_files`, repsectively.
2816

2817
        Note:
2818
            calls `pyemu.helpers.pst_from_io_files()`
2819

2820
            Assigns generic values for parameter info.  Tries to use INSCHEK
2821
            to set somewhat meaningful observation values
2822

2823
            all file paths are relatively to where python is running.
2824

2825

2826
        Example::
2827

2828
            tpl_files = ["my.tpl"]
2829
            in_files = ["my.in"]
2830
            ins_files = ["my.ins"]
2831
            out_files = ["my.out"]
2832
            pst = pyemu.Pst.from_io_files(tpl_files,in_files,ins_files,out_files)
2833
            pst.control_data.noptmax = 0
2834
            pst.write("my.pst)
2835

2836

2837

2838
        """
2839
        from pyemu import helpers
9✔
2840

2841
        return helpers.pst_from_io_files(
9✔
2842
            tpl_files=tpl_files,
2843
            in_files=in_files,
2844
            ins_files=ins_files,
2845
            out_files=out_files,
2846
            pst_filename=pst_filename,
2847
            pst_path=pst_path,
2848
        )
2849

2850
    def add_parameters(self, template_file, in_file=None, pst_path=None):
9✔
2851
        """add new parameters to an existing control file
2852

2853
        Args:
2854
            template_file (`str`): template file with (possibly) some new parameters
2855
            in_file (`str`): model input file. If None, template_file.replace('.tpl','') is used.
2856
                Default is None.
2857
            pst_path (`str`): the path to append to the template_file and in_file in the control file.  If
2858
                not None, then any existing path in front of the template or in file is split off
2859
                and pst_path is prepended.  If python is being run in a directory other than where the control
2860
                file will reside, it is useful to pass `pst_path` as `.`.  Default is None
2861

2862
        Returns:
2863
            `pandas.DataFrame`: the data for the new parameters that were added.
2864
            If no new parameters are in the new template file, returns None
2865

2866
        Note:
2867
            populates the new parameter information with default values
2868

2869
        Example::
2870

2871
            pst = pyemu.Pst(os.path.join("template","my.pst"))
2872
            pst.add_parameters(os.path.join("template","new_pars.dat.tpl",pst_path=".")
2873
            pst.write(os.path.join("template","my_new.pst")
2874

2875
        """
2876
        if not os.path.exists(template_file):
9✔
2877
            raise Exception("template file '{0}' not found".format(template_file))
×
2878
        if template_file == in_file:
9✔
2879
            raise Exception("template_file == in_file")
×
2880
        # get the parameter names in the template file
2881
        parnme = pst_utils.parse_tpl_file(template_file)
9✔
2882

2883
        parval1 = pst_utils.try_read_input_file_with_tpl(template_file, in_file)
9✔
2884

2885
        # find "new" parameters that are not already in the control file
2886
        new_parnme = [p for p in parnme if p not in self.parameter_data.parnme]
9✔
2887

2888
        if len(new_parnme) == 0:
9✔
2889
            warnings.warn(
×
2890
                "no new parameters found in template file {0}".format(template_file),
2891
                PyemuWarning,
2892
            )
2893
            new_par_data = None
×
2894
        else:
2895
            # extend pa
2896
            # rameter_data
2897
            new_par_data = pst_utils._populate_dataframe(
9✔
2898
                new_parnme,
2899
                pst_utils.pst_config["par_fieldnames"],
2900
                pst_utils.pst_config["par_defaults"],
2901
                pst_utils.pst_config["par_dtype"],
2902
            )
2903
            new_par_data.loc[new_parnme, "parnme"] = new_parnme
9✔
2904
            self.parameter_data = pd.concat([self.parameter_data, new_par_data])
9✔
2905
            if parval1 is not None:
9✔
2906
                parval1 = parval1.loc[new_par_data.parnme]
×
2907
                new_par_data.loc[parval1.parnme, "parval1"] = parval1.parval1
×
2908
        if in_file is None:
9✔
2909
            in_file = template_file.replace(".tpl", "")
9✔
2910
        if pst_path is not None:
9✔
2911
            template_file = os.path.join(pst_path, os.path.split(template_file)[-1])
9✔
2912
            in_file = os.path.join(pst_path, os.path.split(in_file)[-1])
9✔
2913

2914
        # self.template_files.append(template_file)
2915
        # self.input_files.append(in_file)
2916
        self.model_input_data.loc[template_file, "pest_file"] = template_file
9✔
2917
        self.model_input_data.loc[template_file, "model_file"] = in_file
9✔
2918
        print(
9✔
2919
            "{0} pars added from template file {1}".format(
2920
                len(new_parnme), template_file
2921
            )
2922
        )
2923
        return new_par_data
9✔
2924

2925
    def drop_observations(self, ins_file, pst_path=None):
9✔
2926
        """remove observations in an instruction file from the control file
2927

2928
        Args:
2929
            ins_file (`str`): instruction file to remove
2930
            pst_path (`str`): the path to append to the instruction file in the control file.  If
2931
                not None, then any existing path in front of the instruction is split off
2932
                and pst_path is prepended.  If python is being run in a directory other than where the control
2933
                file will reside, it is useful to pass `pst_path` as `.`. Default is None
2934

2935
        Returns:
2936
            `pandas.DataFrame`: the observation data for the observations that were removed.
2937

2938
        Example::
2939

2940
            pst = pyemu.Pst(os.path.join("template", "my.pst"))
2941
            pst.remove_observations(os.path.join("template","some_obs.dat.ins"), pst_path=".")
2942
            pst.write(os.path.join("template", "my_new_with_less_obs.pst")
2943

2944
        """
2945

2946
        if not os.path.exists(ins_file):
8✔
2947
            raise Exception("couldn't find instruction file '{0}'".format(ins_file))
×
2948
        pst_ins_file = ins_file
8✔
2949
        if pst_path is not None:
8✔
2950
            pst_ins_file = os.path.join(pst_path, os.path.split(ins_file)[1])
8✔
2951
        if pst_ins_file not in self.model_output_data.pest_file.to_list():
8✔
2952
            if pst_path == ".":
×
2953
                pst_ins_file = os.path.split(ins_file)[1]
×
2954
                if pst_ins_file not in self.model_output_data.pest_file.to_list():
×
2955
                    raise Exception(
×
2956
                        "ins_file '{0}' not found in Pst.model_output_data.pest_file".format(
2957
                            pst_ins_file
2958
                        )
2959
                    )
2960
            else:
2961
                raise Exception(
×
2962
                    "ins_file '{0}' not found in Pst.model_output_data.pest_file".format(
2963
                        pst_ins_file
2964
                    )
2965
                )
2966
        i = pst_utils.InstructionFile(ins_file)
8✔
2967
        drop_obs = i.obs_name_set
8✔
2968

2969
        # if len(drop_obs) == self.nobs:
2970
        #    raise Exception("cannot drop all observations")
2971

2972
        obs_names = set(self.obs_names)
8✔
2973
        drop_obs_present = [o for o in drop_obs if o in obs_names]
8✔
2974
        dropped_obs = self.observation_data.loc[drop_obs_present, :].copy()
8✔
2975
        self.observation_data = self.observation_data.loc[
8✔
2976
            self.observation_data.obsnme.apply(lambda x: x not in drop_obs), :
2977
        ]
2978
        self.model_output_data = self.model_output_data.loc[
8✔
2979
            self.model_output_data.pest_file != pst_ins_file
2980
        ]
2981
        print(
8✔
2982
            "{0} obs dropped from instruction file {1}".format(len(drop_obs), ins_file)
2983
        )
2984
        return dropped_obs
8✔
2985

2986
    def drop_parameters(self, tpl_file, pst_path=None):
9✔
2987
        """remove parameters in a template file from the control file
2988

2989
        Args:
2990
            tpl_file (`str`): template file to remove
2991
            pst_path (`str`): the path to append to the template file in the control file.  If
2992
                not None, then any existing path in front of the template or in file is split off
2993
                and pst_path is prepended.  If python is being run in a directory other than where the control
2994
                file will reside, it is useful to pass `pst_path` as `.`. Default is None
2995

2996
        Returns:
2997
            `pandas.DataFrame`: the parameter data for the parameters that were removed.
2998

2999
        Note:
3000
            This method does not check for multiple occurences of the same parameter name(s) in
3001
            across template files so if you have the same parameter in multiple template files,
3002
            this is not the method you are looking for
3003

3004
        Example::
3005

3006
            pst = pyemu.Pst(os.path.join("template", "my.pst"))
3007
            pst.remove_parameters(os.path.join("template","boring_zone_pars.dat.tpl"), pst_path=".")
3008
            pst.write(os.path.join("template", "my_new_with_less_pars.pst")
3009

3010
        """
3011

3012
        if not os.path.exists(tpl_file):
8✔
3013
            raise Exception("couldn't find template file '{0}'".format(tpl_file))
×
3014
        pst_tpl_file = tpl_file
8✔
3015
        if pst_path is not None:
8✔
3016
            pst_tpl_file = os.path.join(pst_path, os.path.split(tpl_file)[1])
8✔
3017
        if pst_tpl_file not in self.model_input_data.pest_file.to_list():
8✔
3018
            if pst_path == ".":
×
3019
                pst_tpl_file = os.path.split(tpl_file)[1]
×
3020
                if pst_tpl_file not in self.model_input_data.pest_file.to_list():
×
3021
                    raise Exception(
×
3022
                        "tpl_file '{0}' not found in Pst.model_input_data.pest_file".format(
3023
                            pst_tpl_file
3024
                        )
3025
                    )
3026
            else:
3027
                raise Exception(
×
3028
                    "tpl_file '{0}' not found in Pst.model_input_data.pest_file".format(
3029
                        pst_tpl_file
3030
                    )
3031
                )
3032
        drop_pars = pst_utils.parse_tpl_file(tpl_file)
8✔
3033
        if len(drop_pars) == self.npar:
8✔
3034
            raise Exception("cannot drop all parameters")
×
3035

3036
        # get a list of drop pars that are in parameter_data
3037
        par_names = set(self.par_names)
8✔
3038
        drop_pars_present = [p for p in drop_pars if p in par_names]
8✔
3039

3040
        # check that other pars arent tied to the dropping pars
3041
        if "partied" in self.parameter_data.columns:
8✔
3042
            par_tied = set(
8✔
3043
                self.parameter_data.loc[
3044
                    self.parameter_data.partrans == "tied", "partied"
3045
                ].to_list()
3046
            )
3047

3048
            par_tied = par_tied.intersection(drop_pars_present)
8✔
3049
            if len(par_tied) > 0:
8✔
3050
                raise Exception(
8✔
3051
                    "the following pars to be dropped are 'tied' to: {0}".format(
3052
                        str(par_tied)
3053
                    )
3054
                )
3055

3056
        dropped_par = self.parameter_data.loc[drop_pars_present, :].copy()
8✔
3057
        self.parameter_data = self.parameter_data.loc[
8✔
3058
            self.parameter_data.parnme.apply(lambda x: x not in drop_pars_present), :
3059
        ]
3060
        self.rectify_pi()
8✔
3061
        self.model_input_data = self.model_input_data.loc[
8✔
3062
            self.model_input_data.pest_file != pst_tpl_file
3063
        ]
3064
        print(
8✔
3065
            "{0} pars dropped from template file {1}".format(len(drop_pars), tpl_file)
3066
        )
3067
        return dropped_par
8✔
3068

3069
    def add_observations(self, ins_file, out_file=None, pst_path=None, inschek=True):
9✔
3070
        """add new observations to a control file
3071

3072
        Args:
3073
            ins_file (`str`): instruction file with exclusively new observation names
3074
            out_file (`str`): model output file.  If None, then ins_file.replace(".ins","") is used.
3075
                Default is None
3076
            pst_path (`str`): the path to append to the instruction file and out file in the control file.  If
3077
                not None, then any existing path in front of the template or in file is split off
3078
                and pst_path is prepended.  If python is being run in a directory other than where the control
3079
                file will reside, it is useful to pass `pst_path` as `.`. Default is None
3080
            inschek (`bool`): flag to try to process the existing output file using the `pyemu.InstructionFile`
3081
                class.  If successful, processed outputs are used as obsvals
3082

3083
        Returns:
3084
            `pandas.DataFrame`: the data for the new observations that were added
3085

3086
        Note:
3087
            populates the new observation information with default values
3088

3089
        Example::
3090

3091
            pst = pyemu.Pst(os.path.join("template", "my.pst"))
3092
            pst.add_observations(os.path.join("template","new_obs.dat.ins"), pst_path=".")
3093
            pst.write(os.path.join("template", "my_new.pst")
3094

3095
        """
3096
        if not os.path.exists(ins_file):
8✔
3097
            raise Exception(
×
3098
                "ins file not found: {0}, {1}".format(os.getcwd(), ins_file)
3099
            )
3100
        if out_file is None:
8✔
3101
            out_file = ins_file.replace(".ins", "")
×
3102
        if ins_file == out_file:
8✔
3103
            raise Exception("ins_file == out_file, doh!")
×
3104

3105
        # get the parameter names in the template file
3106
        obsnme = pst_utils.parse_ins_file(ins_file)
8✔
3107

3108
        sobsnme = set(obsnme)
8✔
3109
        sexist = set(self.obs_names)
8✔
3110
        sint = sobsnme.intersection(sexist)
8✔
3111
        if len(sint) > 0:
8✔
3112
            raise Exception(
×
3113
                "the following obs in instruction file {0} are already in the control file:{1}".format(
3114
                    ins_file, ",".join(sint)
3115
                )
3116
            )
3117

3118
        # extend observation_data
3119
        new_obs_data = pst_utils._populate_dataframe(
8✔
3120
            obsnme,
3121
            pst_utils.pst_config["obs_fieldnames"],
3122
            pst_utils.pst_config["obs_defaults"],
3123
            pst_utils.pst_config["obs_dtype"],
3124
        )
3125
        new_obs_data.loc[obsnme, "obsnme"] = obsnme
8✔
3126
        new_obs_data.index = obsnme
8✔
3127
        self.observation_data = pd.concat([self.observation_data, new_obs_data])
8✔
3128
        cwd = "."
8✔
3129
        if pst_path is not None:
8✔
3130
            cwd = os.path.join(*os.path.split(ins_file)[:-1])
×
3131
            ins_file = os.path.join(pst_path, os.path.split(ins_file)[-1])
×
3132
            out_file = os.path.join(pst_path, os.path.split(out_file)[-1])
×
3133
        # self.instruction_files.append(ins_file)
3134
        # self.output_files.append(out_file)
3135
        self.model_output_data.loc[ins_file, "pest_file"] = ins_file
8✔
3136
        self.model_output_data.loc[ins_file, "model_file"] = out_file
8✔
3137
        df = None
8✔
3138
        if inschek:
8✔
3139
            # df = pst_utils._try_run_inschek(ins_file,out_file,cwd=cwd)
3140
            ins_file = os.path.join(cwd, ins_file)
8✔
3141
            out_file = os.path.join(cwd, out_file)
8✔
3142
            df = pst_utils.try_process_output_file(
8✔
3143
                ins_file=ins_file, output_file=out_file
3144
            )
3145
        if df is not None:
8✔
3146
            # print(self.observation_data.index,df.index)
3147
            self.observation_data.loc[df.index, "obsval"] = df.obsval
8✔
3148
            new_obs_data.loc[df.index, "obsval"] = df.obsval
8✔
3149
        print("{0} obs added from instruction file {1}".format(len(obsnme), ins_file))
8✔
3150
        return new_obs_data
8✔
3151

3152
    def write_input_files(self, pst_path="."):
9✔
3153
        """writes model input files using template files and current `parval1` values.
3154

3155
        Args:
3156
            pst_path (`str`): the path to where control file and template files reside.
3157
                Default is '.'
3158

3159
        Note:
3160
            adds "parval1_trans" column to Pst.parameter_data that includes the
3161
            effect of scale and offset
3162

3163
        Example::
3164

3165
            pst = pyemu.Pst("my.pst")
3166

3167
            # load final parameter values
3168
            pst.parrep("my.par")
3169

3170
            # write new model input files with final parameter values
3171
            pst.write_input_files()
3172

3173
        """
3174
        pst_utils.write_input_files(self, pst_path=pst_path)
8✔
3175

3176
    def process_output_files(self, pst_path="."):
9✔
3177
        """processing the model output files using the instruction files
3178
        and existing model output files.
3179

3180
        Args:
3181
            pst_path (`str`): relative path from where python is running to
3182
                where the control file, instruction files and model output files
3183
                are located.  Default is "." (current python directory)
3184

3185
        Returns:
3186
            `pandas.Series`: model output values
3187

3188
        Note:
3189
            requires a complete set of model input files at relative path
3190
            from where python is running to `pst_path`
3191

3192
        Example::
3193

3194
            pst = pyemu.Pst("pest.pst")
3195
            obsvals = pst.process_output_files()
3196
            print(obsvals)
3197

3198
        """
3199
        return pst_utils.process_output_files(self, pst_path)
×
3200

3201
    def get_res_stats(self, nonzero=True):
9✔
3202
        """get some common residual stats by observation group.
3203

3204
        Args:
3205
            nonzero (`bool`): calculate stats using only nonzero-weighted observations.  This may seem
3206
                obsvious to most users, but you never know....
3207

3208
        Returns:
3209
            `pd.DataFrame`: a dataframe with columns for groups names and indices of statistic name.
3210

3211
        Note:
3212
            Stats are derived from the current obsvals, weights and grouping in
3213
            `Pst.observation_data` and the `modelled` values in `Pst.res`.  The
3214
            key here is 'current' because if obsval, weights and/or groupings have
3215
            changed in `Pst.observation_data` since the residuals file was generated
3216
            then the current values for `obsval`, `weight` and `group` are used
3217

3218
            the normalized RMSE is normalized against the obsval range (max - min)
3219

3220
        Example::
3221

3222
            pst = pyemu.Pst("pest.pst")
3223
            stats_df = pst.get_res_stats()
3224
            print(stats_df.loc["mae",:])
3225

3226

3227
        """
3228
        res = self.res.copy()
8✔
3229
        res.loc[:, "obsnme"] = res.pop("name")
8✔
3230
        res.index = res.obsnme
8✔
3231
        if nonzero:
8✔
3232
            obs = self.observation_data.loc[self.nnz_obs_names, :]
8✔
3233
        # print(obs.shape,res.shape)
3234
        res = res.loc[obs.obsnme, :]
8✔
3235
        # print(obs.shape, res.shape)
3236

3237
        # reset the res parts to current obs values and remove
3238
        # duplicate attributes
3239
        res.loc[:, "weight"] = obs.weight
8✔
3240
        res.loc[:, "obsval"] = obs.obsval
8✔
3241
        res.loc[:, "obgnme"] = obs.obgnme
8✔
3242
        res.pop("group")
8✔
3243
        res.pop("measured")
8✔
3244

3245
        # build these attribute lists for faster lookup later
3246
        og_dict = {
8✔
3247
            og: res.loc[res.obgnme == og, "obsnme"] for og in res.obgnme.unique()
3248
        }
3249
        og_names = list(og_dict.keys())
8✔
3250

3251
        # the list of functions and names
3252
        sfuncs = [
8✔
3253
            self._stats_rss,
3254
            self._stats_mean,
3255
            self._stats_mae,
3256
            self._stats_rmse,
3257
            self._stats_nrmse,
3258
        ]
3259
        snames = ["rss", "mean", "mae", "rmse", "nrmse"]
8✔
3260

3261
        data = []
8✔
3262
        for sfunc in sfuncs:
8✔
3263
            full = sfunc(res)
8✔
3264
            groups = [full]
8✔
3265
            for og in og_names:
8✔
3266
                onames = og_dict[og]
8✔
3267
                res_og = res.loc[onames, :]
8✔
3268
                groups.append(sfunc(res_og))
8✔
3269
            data.append(groups)
8✔
3270

3271
        og_names.insert(0, "all")
8✔
3272
        stats = pd.DataFrame(data, columns=og_names, index=snames)
8✔
3273
        return stats
8✔
3274

3275
    @staticmethod
9✔
3276
    def _stats_rss(df):
9✔
3277
        return (((df.modelled - df.obsval) * df.weight) ** 2).sum()
8✔
3278

3279
    @staticmethod
9✔
3280
    def _stats_mean(df):
9✔
3281
        return (df.modelled - df.obsval).mean()
8✔
3282

3283
    @staticmethod
9✔
3284
    def _stats_mae(df):
9✔
3285
        return ((df.modelled - df.obsval).apply(np.abs)).sum() / df.shape[0]
8✔
3286

3287
    @staticmethod
9✔
3288
    def _stats_rmse(df):
9✔
3289
        return np.sqrt(((df.modelled - df.obsval) ** 2).sum() / df.shape[0])
8✔
3290

3291
    @staticmethod
9✔
3292
    def _stats_nrmse(df):
9✔
3293
        return Pst._stats_rmse(df) / (df.obsval.max() - df.obsval.min())
8✔
3294

3295
    def plot(self, kind=None, **kwargs):
9✔
3296
        """method to plot various parts of the control.  This is sweet as!
3297

3298
        Args:
3299
            kind (`str`): options are 'prior' (prior parameter histograms, '1to1' (line of equality
3300
                and sim vs res), 'obs_v_sim' (time series using datetime suffix), 'phi_pie'
3301
                (pie chart of phi components)
3302
            kwargs (`dict`): optional args for plots that are passed to pyemu plot helpers and ultimately
3303
                to matplotlib
3304

3305
        Note:
3306
            Depending on 'kind' argument, a multipage pdf is written
3307

3308
        Example::
3309

3310
            pst = pyemu.Pst("my.pst")
3311
            pst.plot(kind="1to1") # requires Pst.res
3312
            pst.plot(kind="prior")
3313
            pst.plot(kind="phi_pie")
3314

3315

3316
        """
3317
        return plot_utils.pst_helper(self, kind, **kwargs)
9✔
3318

3319
    def write_par_summary_table(
9✔
3320
        self,
3321
        filename=None,
3322
        group_names=None,
3323
        sigma_range=4.0,
3324
        report_in_linear_space=False,
3325
    ):
3326
        """write a stand alone parameter summary latex table or Excel sheet
3327

3328

3329
        Args:
3330
            filename (`str`): filename. If None, use <case>.par.tex to write as LaTeX. If filename extention is '.xls' or '.xlsx',
3331
                tries to write as an Excel file. If `filename` is "none", no table is written
3332
                Default is None
3333
            group_names (`dict`): par group names : table names. For example {"w0":"well stress period 1"}.
3334
                Default is None
3335
            sigma_range (`float`): number of standard deviations represented by parameter bounds.  Default
3336
                is 4.0, implying 95% confidence bounds
3337
            report_in_linear_space (`bool`): flag, if True, that reports all logtransformed values in linear
3338
                space. This renders standard deviation meaningless, so that column is skipped
3339

3340
        Returns:
3341
            `pandas.DataFrame`: the summary parameter group dataframe
3342

3343
        Example::
3344

3345
            pst = pyemu.Pst("my.pst")
3346
            pst.write_par_summary_table(filename="par.tex")
3347

3348
        """
3349

3350
        ffmt = lambda x: "{0:5G}".format(x)
9✔
3351
        par = self.parameter_data.copy()
9✔
3352
        pargp = par.groupby(par.pargp).groups
9✔
3353
        # cols = ["parval1","parubnd","parlbnd","stdev","partrans","pargp"]
3354
        if report_in_linear_space == True:
9✔
3355
            cols = ["pargp", "partrans", "count", "parval1", "parlbnd", "parubnd"]
8✔
3356

3357
        else:
3358
            cols = [
9✔
3359
                "pargp",
3360
                "partrans",
3361
                "count",
3362
                "parval1",
3363
                "parlbnd",
3364
                "parubnd",
3365
                "stdev",
3366
            ]
3367

3368
        labels = {
9✔
3369
            "parval1": "initial value",
3370
            "parubnd": "upper bound",
3371
            "parlbnd": "lower bound",
3372
            "partrans": "transform",
3373
            "stdev": "standard deviation",
3374
            "pargp": "type",
3375
            "count": "count",
3376
        }
3377

3378
        li = par.partrans == "log"
9✔
3379
        if True in li.values and report_in_linear_space == True:
9✔
3380
            print(
8✔
3381
                "Warning: because log-transformed values being reported in linear space, stdev NOT reported"
3382
            )
3383

3384
        if report_in_linear_space == False:
9✔
3385
            par.loc[li, "parval1"] = par.parval1.loc[li].apply(np.log10)
9✔
3386
            par.loc[li, "parubnd"] = par.parubnd.loc[li].apply(np.log10)
9✔
3387
            par.loc[li, "parlbnd"] = par.parlbnd.loc[li].apply(np.log10)
9✔
3388
            par.loc[:, "stdev"] = (par.parubnd - par.parlbnd) / sigma_range
9✔
3389

3390
        data = {c: [] for c in cols}
9✔
3391
        for pg, pnames in pargp.items():
9✔
3392
            par_pg = par.loc[pnames, :]
9✔
3393
            data["pargp"].append(pg)
9✔
3394
            for col in cols:
9✔
3395
                if col in ["pargp", "partrans"]:
9✔
3396
                    continue
9✔
3397
                if col == "count":
9✔
3398
                    data["count"].append(par_pg.shape[0])
9✔
3399
                    continue
9✔
3400
                # print(col)
3401
                mn = par_pg.loc[:, col].min()
9✔
3402
                mx = par_pg.loc[:, col].max()
9✔
3403
                if mn == mx:
9✔
3404
                    data[col].append(ffmt(mn))
9✔
3405
                else:
3406
                    data[col].append("{0} to {1}".format(ffmt(mn), ffmt(mx)))
8✔
3407

3408
            pts = par_pg.partrans.unique()
9✔
3409
            if len(pts) == 1:
9✔
3410
                data["partrans"].append(pts[0])
9✔
3411
            else:
3412
                data["partrans"].append("mixed")
×
3413

3414
        pargp_df = pd.DataFrame(data=data, index=list(pargp.keys()))
9✔
3415
        pargp_df = pargp_df.loc[:, cols]
9✔
3416
        if group_names is not None:
9✔
3417
            pargp_df.loc[:, "pargp"] = pargp_df.pargp.apply(
8✔
3418
                lambda x: group_names.pop(x, x)
3419
            )
3420
        pargp_df.columns = pargp_df.columns.map(lambda x: labels[x])
9✔
3421

3422
        preamble = (
9✔
3423
            "\\documentclass{article}\n\\usepackage{booktabs}\n"
3424
            + "\\usepackage{pdflscape}\n\\usepackage{longtable}\n"
3425
            + "\\usepackage{booktabs}\n\\usepackage{nopageno}\n\\begin{document}\n"
3426
        )
3427

3428
        if filename == "none":
9✔
3429
            return pargp_df
×
3430
        if filename is None:
9✔
3431
            filename = self.filename.replace(".pst", ".par.tex")
9✔
3432
        # if filename indicates an Excel format, try writing to Excel
3433
        if filename.lower().endswith("xlsx") or filename.lower().endswith("xls"):
9✔
3434
            try:
8✔
3435
                pargp_df.to_excel(filename, index=None)
8✔
3436
            except Exception as e:
8✔
3437
                if filename.lower().endswith("xlsx"):
8✔
3438
                    print(
8✔
3439
                        "could not export {0} in Excel format. Try installing xlrd".format(
3440
                            filename
3441
                        )
3442
                    )
3443
                elif filename.lower().endswith("xls"):
×
3444
                    print(
×
3445
                        "could not export {0} in Excel format. Try installing xlwt".format(
3446
                            filename
3447
                        )
3448
                    )
3449
                else:
3450
                    print("could not export {0} in Excel format.".format(filename))
×
3451

3452
        else:
3453
            with open(filename, "w") as f:
9✔
3454
                f.write(preamble)
9✔
3455
                f.write("\\begin{center}\nParameter Summary\n\\end{center}\n")
9✔
3456
                f.write("\\begin{center}\n\\begin{landscape}\n")
9✔
3457
                try:
9✔
3458
                    f.write(pargp_df.style.hide(axis='index').to_latex(
9✔
3459
                        None, environment='longtable')
3460
                    )
3461
                except (TypeError, AttributeError) as e:
×
3462
                    pargp_df.to_latex(index=False, longtable=True)
×
3463
                f.write("\\end{landscape}\n")
9✔
3464
                f.write("\\end{center}\n")
9✔
3465
                f.write("\\end{document}\n")
9✔
3466
        return pargp_df
9✔
3467

3468
    def write_obs_summary_table(self, filename=None, group_names=None):
9✔
3469
        """write a stand alone observation summary latex table or Excel shet
3470
            filename (`str`): filename. If None, use <case>.par.tex to write as LaTeX. If filename extention is '.xls' or '.xlsx',
3471
                tries to write as an Excel file. If `filename` is "none", no table is written
3472
                Default is None
3473

3474
        Args:
3475
            filename (`str`): filename. If `filename` is "none", no table is written.
3476
                If None, use <case>.obs.tex. If filename extention is '.xls' or '.xlsx',
3477
                tries to write as an Excel file.
3478
                Default is None
3479
            group_names (`dict`): obs group names : table names. For example {"hds":"simulated groundwater level"}.
3480
                Default is None
3481

3482
        Returns:
3483
            `pandas.DataFrame`: the summary observation group dataframe
3484

3485
        Example::
3486

3487
            pst = pyemu.Pst("my.pst")
3488
            pst.write_obs_summary_table(filename="obs.tex")
3489
        """
3490

3491
        ffmt = lambda x: "{0:5G}".format(x)
9✔
3492
        obs = self.observation_data.copy()
9✔
3493
        obsgp = obs.groupby(obs.obgnme).groups
9✔
3494
        cols = ["obgnme", "obsval", "nzcount", "zcount", "weight", "stdev", "pe"]
9✔
3495

3496
        labels = {
9✔
3497
            "obgnme": "group",
3498
            "obsval": "value",
3499
            "nzcount": "non-zero weight",
3500
            "zcount": "zero weight",
3501
            "weight": "weight",
3502
            "stdev": "standard deviation",
3503
            "pe": "percent error",
3504
        }
3505

3506
        obs["stdev"] = obs.weight**-1
9✔
3507
        obs["pe"] = 100.0 * (obs.stdev / obs.obsval.abs())
9✔
3508
        obs = obs.replace([np.inf, -np.inf], np.NaN)
9✔
3509

3510
        data = {c: [] for c in cols}
9✔
3511
        for og, onames in obsgp.items():
9✔
3512
            obs_g = obs.loc[onames, :]
9✔
3513
            data["obgnme"].append(og)
9✔
3514
            data["nzcount"].append(obs_g.loc[obs_g.weight > 0.0, :].shape[0])
9✔
3515
            data["zcount"].append(obs_g.loc[obs_g.weight == 0.0, :].shape[0])
9✔
3516
            for col in cols:
9✔
3517
                if col in ["obgnme", "nzcount", "zcount"]:
9✔
3518
                    continue
9✔
3519

3520
                # print(col)
3521
                mn = obs_g.loc[:, col].min()
9✔
3522
                mx = obs_g.loc[:, col].max()
9✔
3523
                if np.isnan(mn) or np.isnan(mx):
9✔
3524
                    data[col].append("NA")
8✔
3525
                elif mn == mx:
9✔
3526
                    data[col].append(ffmt(mn))
9✔
3527
                else:
3528
                    data[col].append("{0} to {1}".format(ffmt(mn), ffmt(mx)))
9✔
3529

3530
        obsg_df = pd.DataFrame(data=data, index=list(obsgp.keys()))
9✔
3531
        obsg_df = obsg_df.loc[:, cols]
9✔
3532
        if group_names is not None:
9✔
3533
            obsg_df.loc[:, "obgnme"] = obsg_df.obgnme.apply(
8✔
3534
                lambda x: group_names.pop(x, x)
3535
            )
3536
        obsg_df.sort_values(by="obgnme", inplace=True, ascending=True)
9✔
3537
        obsg_df.columns = obsg_df.columns.map(lambda x: labels[x])
9✔
3538

3539
        preamble = (
9✔
3540
            "\\documentclass{article}\n\\usepackage{booktabs}\n"
3541
            + "\\usepackage{pdflscape}\n\\usepackage{longtable}\n"
3542
            + "\\usepackage{booktabs}\n\\usepackage{nopageno}\n\\begin{document}\n"
3543
        )
3544

3545
        if filename == "none":
9✔
3546
            return obsg_df
×
3547
        if filename is None:
9✔
3548
            filename = self.filename.replace(".pst", ".obs.tex")
9✔
3549
        # if filename indicates an Excel format, try writing to Excel
3550
        if filename.lower().endswith("xlsx") or filename.lower().endswith("xls"):
9✔
3551
            try:
8✔
3552
                obsg_df.to_excel(filename, index=None)
8✔
3553
            except Exception as e:
8✔
3554
                if filename.lower().endswith("xlsx"):
8✔
3555
                    print(
8✔
3556
                        "could not export {0} in Excel format. Try installing xlrd".format(
3557
                            filename
3558
                        )
3559
                    )
3560
                elif filename.lower().endswith("xls"):
×
3561
                    print(
×
3562
                        "could not export {0} in Excel format. Try installing xlwt".format(
3563
                            filename
3564
                        )
3565
                    )
3566
                else:
3567
                    print("could not export {0} in Excel format.".format(filename))
×
3568

3569
        else:
3570
            with open(filename, "w") as f:
9✔
3571

3572
                f.write(preamble)
9✔
3573

3574
                f.write("\\begin{center}\nObservation Summary\n\\end{center}\n")
9✔
3575
                f.write("\\begin{center}\n\\begin{landscape}\n")
9✔
3576
                f.write("\\setlength{\\LTleft}{-4.0cm}\n")
9✔
3577
                try:
9✔
3578
                    f.write(obsg_df.style.hide(axis='index').to_latex(
9✔
3579
                        None, environment='longtable')
3580
                    )
3581
                except (TypeError, AttributeError) as e:
×
3582
                    obsg_df.to_latex(index=False, longtable=True)
×
3583
                f.write("\\end{landscape}\n")
9✔
3584
                f.write("\\end{center}\n")
9✔
3585
                f.write("\\end{document}\n")
9✔
3586

3587
        return obsg_df
9✔
3588

3589
    # jwhite - 13 Aug 2019 - no one is using this write?
3590
    # def run(self,exe_name="pestpp",cwd=None):
3591
    #     """run a command related to the pst instance. If
3592
    #     write() has been called, then the filename passed to write
3593
    #     is in the command, otherwise the original constructor
3594
    #     filename is used
3595
    #
3596
    #     exe_name : str
3597
    #         the name of the executable to call.  Default is "pestpp"
3598
    #     cwd : str
3599
    #         the directory to execute the command in.  If None,
3600
    #         os.path.split(self.filename) is used to find
3601
    #         cwd.  Default is None
3602
    #
3603
    #
3604
    #     """
3605
    #     filename = self.filename
3606
    #     if self.new_filename is not None:
3607
    #         filename = self.new_filename
3608
    #     cmd_line = "{0} {1}".format(exe_name,os.path.split(filename)[-1])
3609
    #     if cwd is None:
3610
    #         cwd = os.path.join(*os.path.split(filename)[:-1])
3611
    #         if cwd == '':
3612
    #             cwd = '.'
3613
    #     print("executing {0} in dir {1}".format(cmd_line, cwd))
3614
    #     pyemu.utils.os_utils.run(cmd_line,cwd=cwd)
3615

3616
    # @staticmethod
3617
    # def _is_less_const(name):
3618
    #     constraint_tags = ["l_", "less"]
3619
    #     return True in [True for c in constraint_tags if name.startswith(c)]
3620

3621
    @property
9✔
3622
    def less_than_obs_constraints(self):
9✔
3623
        """get the names of the observations that
3624
        are listed as active (non-zero weight) less than inequality constraints.
3625

3626
        Returns:
3627
            `pandas.Series`: names of observations that are non-zero weighted less
3628
            than constraints (`obgnme` starts with 'l_' or "less")
3629

3630
        Note:
3631
             Zero-weighted obs are skipped
3632

3633
        """
3634
        obs = self.observation_data
8✔
3635
        lt_obs = obs.loc[
8✔
3636
            obs.obgnme.str.startswith(self.get_constraint_tags('lt')) &
3637
            (obs.weight != 0.0), "obsnme"
3638
        ]
3639
        return lt_obs
8✔
3640

3641
    @property
9✔
3642
    def less_than_pi_constraints(self):
9✔
3643
        """get the names of the prior information eqs that
3644
        are listed as active (non-zero weight) less than inequality constraints.
3645

3646
        Returns:
3647
            `pandas.Series`: names of prior information that are non-zero weighted
3648
            less than constraints (`obgnme` starts with "l_" or "less")
3649

3650
        Note:
3651
            Zero-weighted pi are skipped
3652

3653
        """
3654

3655
        pi = self.prior_information
8✔
3656
        lt_pi = pi.loc[
8✔
3657
            pi.obgnme.str.startswith(self.get_constraint_tags('lt')) &
3658
            (pi.weight != 0.0), "pilbl"
3659
        ]
3660
        return lt_pi
8✔
3661

3662
    # @staticmethod
3663
    # def _is_greater_const(name):
3664
    #     constraint_tags = ["g_", "greater"]
3665
    #     return True in [True for c in constraint_tags if name.startswith(c)]
3666

3667
    @property
9✔
3668
    def greater_than_obs_constraints(self):
9✔
3669
        """get the names of the observations that
3670
        are listed as active (non-zero weight) greater than inequality constraints.
3671

3672
        Returns:
3673
            `pandas.Series`: names obseravtions that are non-zero weighted
3674
            greater than constraints (`obgnme` startsiwth "g_" or "greater")
3675

3676
        Note:
3677
            Zero-weighted obs are skipped
3678

3679
        """
3680

3681
        obs = self.observation_data
8✔
3682
        gt_obs = obs.loc[
8✔
3683
            obs.obgnme.str.startswith(self.get_constraint_tags('gt')) &
3684
            (obs.weight != 0.0), "obsnme"
3685
        ]
3686
        return gt_obs
8✔
3687

3688
    @property
9✔
3689
    def greater_than_pi_constraints(self):
9✔
3690
        """get the names of the prior information eqs that
3691
        are listed as active (non-zero weight) greater than inequality constraints.
3692

3693
        Returns:
3694
            `pandas.Series` names of prior information that are non-zero weighted
3695
            greater than constraints (`obgnme` startsiwth "g_" or "greater")
3696

3697

3698
        Note:
3699
             Zero-weighted pi are skipped
3700

3701
        """
3702

3703
        pi = self.prior_information
8✔
3704
        gt_pi = pi.loc[
8✔
3705
            pi.obgnme.str.startswith(self.get_constraint_tags('gt')) &
3706
            (pi.weight != 0.0),
3707
            "pilbl"]
3708
        return gt_pi
8✔
3709

3710
    def get_par_change_limits(self):
9✔
3711
        """calculate the various parameter change limits used in pest.
3712

3713

3714
        Returns:
3715
            `pandas.DataFrame`: a copy of `Pst.parameter_data`
3716
            with columns for relative and factor change limits
3717
        Note:
3718

3719
            does not yet support absolute parameter change limits!
3720

3721
            Works in control file values space (not log transformed space).  Also
3722
            adds columns for effective upper and lower which account for par bounds and the
3723
            value of parchglim
3724

3725
        example::
3726

3727
            pst = pyemu.Pst("pest.pst")
3728
            df = pst.get_par_change_limits()
3729
            print(df.chg_lower)
3730

3731
        """
3732
        par = self.parameter_data
8✔
3733
        fpars = par.loc[par.parchglim == "factor", "parnme"]
8✔
3734
        rpars = par.loc[par.parchglim == "relative", "parnme"]
8✔
3735
        # apars = par.loc[par.parchglim == "absolute", "parnme"]
3736

3737
        change_df = par.copy()
8✔
3738

3739
        fpm = self.control_data.facparmax
8✔
3740
        rpm = self.control_data.relparmax
8✔
3741
        facorig = self.control_data.facorig
8✔
3742
        base_vals = par.parval1.copy()
8✔
3743

3744
        # apply zero value correction
3745
        base_vals[base_vals == 0] = par.loc[base_vals == 0, "parubnd"] / 4.0
8✔
3746

3747
        # apply facorig
3748
        replace_pars = base_vals.index.map(
8✔
3749
            lambda x: par.loc[x, "partrans"] != "log"
3750
            and np.abs(base_vals.loc[x]) < facorig * np.abs(base_vals.loc[x])
3751
        )
3752
        # print(facorig,replace_pars)
3753
        base_vals.loc[replace_pars] = base_vals.loc[replace_pars] * facorig
8✔
3754

3755
        # negative fac pars
3756
        nfpars = par.loc[base_vals.apply(lambda x: x < 0)].index
8✔
3757
        change_df.loc[nfpars, "fac_upper"] = base_vals / fpm
8✔
3758
        change_df.loc[nfpars, "fac_lower"] = base_vals * fpm
8✔
3759

3760
        # postive fac pars
3761
        pfpars = par.loc[base_vals.apply(lambda x: x > 0)].index
8✔
3762
        change_df.loc[pfpars, "fac_upper"] = base_vals * fpm
8✔
3763
        change_df.loc[pfpars, "fac_lower"] = base_vals / fpm
8✔
3764

3765
        # relative
3766

3767
        rdelta = base_vals.apply(np.abs) * rpm
8✔
3768
        change_df.loc[:, "rel_upper"] = base_vals + rdelta
8✔
3769
        change_df.loc[:, "rel_lower"] = base_vals - rdelta
8✔
3770

3771
        change_df.loc[:, "chg_upper"] = np.NaN
8✔
3772
        change_df.loc[fpars, "chg_upper"] = change_df.fac_upper[fpars]
8✔
3773
        change_df.loc[rpars, "chg_upper"] = change_df.rel_upper[rpars]
8✔
3774
        change_df.loc[:, "chg_lower"] = np.NaN
8✔
3775
        change_df.loc[fpars, "chg_lower"] = change_df.fac_lower[fpars]
8✔
3776
        change_df.loc[rpars, "chg_lower"] = change_df.rel_lower[rpars]
8✔
3777

3778
        # effective limits
3779
        change_df.loc[:, "eff_upper"] = change_df.loc[:, ["parubnd", "chg_upper"]].min(
8✔
3780
            axis=1
3781
        )
3782
        change_df.loc[:, "eff_lower"] = change_df.loc[:, ["parlbnd", "chg_lower"]].max(
8✔
3783
            axis=1
3784
        )
3785

3786
        return change_df
8✔
3787

3788
    def get_adj_pars_at_bounds(self, frac_tol=0.01):
9✔
3789
        """get list of adjustable parameter at/near bounds
3790

3791
        Args:
3792
            frac_tol ('float`): fractional tolerance of distance to bound.  For upper bound,
3793
                the value `parubnd * (1-frac_tol)` is used, lower bound uses `parlbnd * (1.0 + frac_tol)`
3794

3795
        Returns:
3796
            tuple containing:
3797

3798
            - **[`str`]**: list of parameters at/near lower bound
3799
            - **[`str`]**: list of parameters at/near upper bound
3800

3801
        Example::
3802

3803
            pst = pyemu.Pst("pest.pst")
3804
            at_lb,at_ub = pst.get_adj_pars_at_bounds()
3805
            print("pars at lower bound",at_lb)
3806

3807
        """
3808

3809
        par = self.parameter_data.loc[self.adj_par_names, :].copy()
8✔
3810
        over_ub = par.loc[
8✔
3811
            par.apply(lambda x: x.parval1 >= (1.0 - frac_tol) * x.parubnd, axis=1),
3812
            "parnme",
3813
        ].tolist()
3814
        under_lb = par.loc[
8✔
3815
            par.apply(lambda x: x.parval1 <= (1.0 + frac_tol) * x.parlbnd, axis=1),
3816
            "parnme",
3817
        ].tolist()
3818

3819
        return under_lb, over_ub
8✔
3820

3821
    def try_parse_name_metadata(self):
9✔
3822
        """try to add meta data columns to parameter and observation data based on
3823
        item names.  Used with the PstFrom process.
3824

3825
        Note:
3826
            metadata is identified in key-value pairs that are separated by a colon.
3827
            each key-value pair is separated from others by underscore
3828

3829
            This works with PstFrom style long names
3830

3831
            This method is called programmtically during `Pst.load()`
3832

3833
        """
3834
        par = self.parameter_data
9✔
3835
        obs = self.observation_data
9✔
3836
        par_cols = pst_utils.pst_config["par_fieldnames"]
9✔
3837
        obs_cols = pst_utils.pst_config["obs_fieldnames"]
9✔
3838

3839
        if "longname" in par.columns:
9✔
3840
            partg = "longname"
8✔
3841
        else:
3842
            partg = "parnme"
9✔
3843
        if "longname" in obs.columns:
9✔
3844
            obstg = "longname"
8✔
3845
        else:
3846
            obstg = "obsnme"
9✔
3847

3848
        for df, name, fieldnames in zip(
9✔
3849
            [par, obs], [partg, obstg], [par_cols, obs_cols]
3850
        ):
3851
            try:
9✔
3852
                meta_dict = df.loc[:, name].apply(
9✔
3853
                    lambda x: dict(
3854
                        [item.split(":") for item in x.split("_") if ":" in item]
3855
                    )
3856
                )
3857
                unique_keys = []
9✔
3858
                for k, v in meta_dict.items():
9✔
3859
                    for kk, vv in v.items():
9✔
3860
                        if kk not in fieldnames and kk not in unique_keys:
9✔
3861
                            unique_keys.append(kk)
9✔
3862
                for uk in unique_keys:
9✔
3863
                    if uk not in df.columns:
9✔
3864
                        df.loc[:, uk] = np.NaN
9✔
3865
                    df.loc[:, uk] = meta_dict.apply(lambda x: x.get(uk, np.NaN))
9✔
3866
            except Exception as e:
×
3867
                print("error parsing metadata from '{0}', continuing".format(name))
×
3868

3869
    def rename_parameters(self, name_dict, pst_path=".", tplmap=None):
9✔
3870
        """rename parameters in the control and template files
3871

3872
        Args:
3873
            name_dict (`dict`): mapping of current to new names.
3874
            pst_path (str): the path to the control file from where python
3875
                is running.  Default is "." (python is running in the
3876
                same directory as the control file)
3877

3878
        Note:
3879
            no attempt is made to maintain the length of the marker strings
3880
            in the template files, so if your model is sensitive
3881
            to changes in spacing in the template file(s), this
3882
            is not a method for you
3883

3884
            This does a lot of string compare, so its gonna be slow as...
3885

3886
         Example::
3887

3888
            pst = pyemu.Pst(os.path.join("template","pest.pst"))
3889
            name_dict = {"par1":"par1_better_name"}
3890
            pst.rename_parameters(name_dict,pst_path="template")
3891

3892

3893

3894
        """
3895

3896
        missing = set(name_dict.keys()) - set(self.par_names)
8✔
3897
        if len(missing) > 0:
8✔
3898
            raise Exception(
8✔
3899
                "Pst.rename_parameters(): the following parameters in 'name_dict'"
3900
                + " are not in the control file:\n{0}".format(",".join(missing))
3901
            )
3902

3903
        par = self.parameter_data
8✔
3904
        par.loc[:, "parnme"] = par.parnme.apply(lambda x: name_dict.get(x, x))
8✔
3905
        par.index = par.parnme.values
8✔
3906

3907
        for idx, eq in zip(
8✔
3908
            self.prior_information.index, self.prior_information.equation
3909
        ):
3910
            for old, new in name_dict.items():
8✔
3911
                eq = eq.replace(old, new)
8✔
3912
            self.prior_information.loc[idx, "equation"] = eq
8✔
3913

3914
        # pad for putting to tpl
3915
        name_dict = {k: v.center(12) for k, v in name_dict.items()}
8✔
3916
        filelist = self.model_input_data.pest_file
8✔
3917
        _replace_str_in_files(filelist, name_dict, file_obsparmap=tplmap,
8✔
3918
                              pst_path=pst_path)
3919

3920

3921
    def rename_observations(self, name_dict, pst_path=".", insmap=None):
9✔
3922
        """rename observations in the control and instruction files
3923

3924
        Args:
3925
            name_dict (`dict`): mapping of current to new names.
3926
            pst_path (str): the path to the control file from where python
3927
                is running.  Default is "." (python is running in the
3928
                same directory as the control file)
3929

3930
        Note:
3931
            This does a lot of string compare, so its gonna be slow as...
3932

3933
         Example::
3934

3935
            pst = pyemu.Pst(os.path.join("template","pest.pst"))
3936
            name_dict = {"obs1":"obs1_better_name"}
3937
            pst.rename_observations(name_dict,pst_path="template")
3938

3939

3940

3941
        """
3942

3943
        missing = set(name_dict.keys()) - set(self.obs_names)
8✔
3944
        if len(missing) > 0:
8✔
3945
            raise Exception(
×
3946
                "Pst.rename_observations(): the following observations in 'name_dict'"
3947
                + " are not in the control file:\n{0}".format(",".join(missing))
3948
            )
3949

3950
        obs = self.observation_data
8✔
3951
        obs.loc[:, "obsnme"] = obs.obsnme.apply(lambda x: name_dict.get(x, x))
8✔
3952
        obs.index = obs.obsnme.values
8✔
3953
        _replace_str_in_files(self.model_output_data.pest_file, name_dict,
8✔
3954
                              file_obsparmap=insmap, pst_path=pst_path)
3955

3956

3957
def _replace_str_in_files(filelist, name_dict, file_obsparmap=None, pst_path='.'):
9✔
3958
    import multiprocessing as mp
8✔
3959
    with mp.get_context("spawn").Pool(
8✔
3960
            processes=min(os.cpu_count()-1, 60)) as pool:
3961
        res = []
8✔
3962
        for fname in filelist:
8✔
3963
            sys_fname = os.path.join(
8✔
3964
                pst_path,
3965
                str(fname).replace("/", os.path.sep).replace("\\", os.path.sep)
3966
            )
3967
            if not os.path.exists(sys_fname):
8✔
3968
                warnings.warn(
8✔
3969
                    "template/instruction file '{0}' not found, continuing...",
3970
                    PyemuWarning
3971
                )
3972
                continue
8✔
3973
            if file_obsparmap is not None:
8✔
3974
                if sys_fname not in file_obsparmap.keys():
8✔
3975
                    continue
8✔
3976
                sub_name_dict = {v: name_dict[v]
8✔
3977
                                 for v in file_obsparmap[sys_fname]}
3978
                rex = None
8✔
3979
            else:
3980
                sub_name_dict = name_dict
8✔
3981
                trie = pyemu.helpers.Trie()
8✔
3982
                [trie.add(onme) for onme in name_dict.keys()]
8✔
3983
                rex = re.compile(trie.pattern())
8✔
3984
            # _multiprocess_obspar_rename(sys_fname, sub_name_dict, rex)
3985
            res.append(pool.apply_async(_multiprocess_obspar_rename,
8✔
3986
                                        args=(sys_fname, sub_name_dict, rex)))
3987
        [r.get for r in res]
8✔
3988
        pool.close()
8✔
3989
        pool.join()
8✔
3990

3991

3992
def _multiprocess_obspar_rename(sys_file, map_dict, rex=None):
9✔
3993
    print(f"    find/replace long->short in {sys_file}")
8✔
3994
    t0 = time.time()
8✔
3995
    _multiprocess_obspar_rename_v3(sys_file, map_dict, rex=rex)
8✔
3996
    # with open(sys_file, "rt") as f:
3997
    #     nl = len(f.readlines())
3998
    # np = len(map_dict)
3999
    # if rex is None:
4000
    #     if np > 1e6:  # regex compile might be the major slowdown
4001
    #         _multiprocess_obspar_rename_v0(sys_file, map_dict)
4002
    #     elif nl > 100:  # favour line by line to conserve mem
4003
    #         _multiprocess_obspar_rename_v2(sys_file, map_dict, rex)
4004
    #     else: # read and replace whole file
4005
    #         _multiprocess_obspar_rename_v1(sys_file, map_dict, rex)
4006
    # else:
4007
    #     if nl > 100:  # favour line by line to conserve mem
4008
    #         _multiprocess_obspar_rename_v2(sys_file, map_dict, rex)
4009
    #     else:  # read and replace whole file
4010
    #         _multiprocess_obspar_rename_v1(sys_file, map_dict, rex)
4011
    shutil.copy(sys_file+".tmp", sys_file)
8✔
4012
    os.remove(sys_file+".tmp")
8✔
4013
    print(f"    find/replace long->short in {sys_file}... "
8✔
4014
          f"took {time.time()-t0: .2f} s")
4015

4016

4017
# def _multiprocess_obspar_rename_v0(sys_file, map_dict):
4018
#     # memory intensive when file is big
4019
#     # slow when file is big & when map_dict is long
4020
#     # although maybe less slow than v1 and v2 when map_dict is the same across
4021
#     # files - unless rex is precompiled outside mp call
4022
#     with open(sys_file, "rt") as f:
4023
#         x = f.read()
4024
#     with open(sys_file+".tmp", "wt") as f:
4025
#         for old in sorted(map_dict.keys(), key=len, reverse=True):
4026
#             x = x.replace(old, map_dict[old])
4027
#         f.write(x)
4028

4029

4030
# def _multiprocess_obspar_rename_v1(sys_file, map_dict, rex=None):
4031
#     # memory intensive as whole file is read into memory
4032
#     # maybe faster than v2 when file is big but map_dict is relativly small
4033
#     # but look out for memory
4034
#     if rex is None:
4035
#         rex = re.compile("|".join(
4036
#             map(re.escape, sorted(map_dict.keys(), key=len, reverse=True))))
4037
#     with open(sys_file, "rt") as f:
4038
#         x = f.read()
4039
#     with open(sys_file+".tmp", "wt") as f:
4040
#         f.write(rex.sub(lambda s: map_dict[s.group()], x))
4041

4042

4043
# def _multiprocess_obspar_rename_v2(sys_file, map_dict, rex=None):
4044
#     # line by line
4045
#     if rex is None:
4046
#         rex = re.compile("|".join(
4047
#             map(re.escape, sorted(map_dict.keys(), key=len, reverse=True))))
4048
#     with open(sys_file, "rt") as f, open(sys_file+'.tmp', 'w') as fo:
4049
#         for line in f:
4050
#             fo.write(rex.sub(lambda s: map_dict[s.group()], line))
4051

4052

4053
def _multiprocess_obspar_rename_v3(sys_file, map_dict, rex=None):
9✔
4054
    # build a trie for rapid regex interaction,
4055
    if rex is None:
8✔
4056
        trie = pyemu.helpers.Trie()
8✔
4057
        _ = [trie.add(word) for word in map_dict.keys()]
8✔
4058
        rex = re.compile(trie.pattern())
8✔
4059
    with open(sys_file, "rt") as f:
8✔
4060
        x = f.read()
8✔
4061
    with open(sys_file + ".tmp", "wt") as f:
8✔
4062
        f.write(rex.sub(lambda s: map_dict[s.group()], x))
8✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc