From fd3191fb56b0c8bf2a0b1e0da85d9f23b640e1c9 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 28 Sep 2026 12:34:56 -0700 Subject: [PATCH 1/3] fix(mflistfile): raise informative error if first budget can't be parsed ListBudget reads budget entry names from the first budget table. If a value in it could not be parsed, _get_sp printed a message and returned the (still empty) null entries, causing an unrelated unpacking error that was then wrapped in a generic Exception. Now a ValueError is raised naming the time step, stress period, and offending line. Behavior for later budget tables (print and fill with NaN) is unchanged. Close #2856 Co-Authored-By: Claude Opus 5.5 --- autotest/test_listbudget.py | 50 +++++++++++++++++++++++++++++++++++++ flopy/utils/mflistfile.py | 46 ++++++++++++++++++++-------------- 2 files changed, 77 insertions(+), 19 deletions(-) diff --git a/autotest/test_listbudget.py b/autotest/test_listbudget.py index 47f438237..cbc58c5d0 100644 --- a/autotest/test_listbudget.py +++ b/autotest/test_listbudget.py @@ -160,3 +160,53 @@ def test_mf6listfile_parse_budget_line_missing_e(value, expected): assert entry == "FLOW-JA-FACE" assert flux == expected assert cumu == expected + + +MF6_BUDGET_TEMPLATE = """ + VOLUME BUDGET FOR ENTIRE MODEL AT END OF TIME STEP 1, STRESS PERIOD {kper} + ------------------------------------------------------------------------------ + + CUMULATIVE VOLUME L**3 RATES FOR THIS TIME STEP L**3/T + + IN: IN: + --- --- + STO = 100.0000 STO = 100.0000 + CHD = {chd:>8} CHD = {chd:>8} + + TOTAL IN = 100.0000 TOTAL IN = 100.0000 + + OUT: OUT: + ---- ---- + STO = 100.0000 STO = 100.0000 + CHD = 0.0000 CHD = 0.0000 + + TOTAL OUT = 100.0000 TOTAL OUT = 100.0000 + + IN - OUT = 0.0000 IN - OUT = 0.0000 + + PERCENT DISCREPANCY = 0.00 PERCENT DISCREPANCY = 0.00 +""" + + +def test_mf6listfile_unparseable_first_entry(function_tmpdir): + # see https://github.com/modflowpy/flopy/issues/2856 + list_file = function_tmpdir / "gwf.lst" + list_file.write_text( + MF6_BUDGET_TEMPLATE.format(kper=1, chd="********") + + MF6_BUDGET_TEMPLATE.format(kper=2, chd="0.0000") + ) + with pytest.raises(ValueError, match=r"first entry.*ts,sp 1 1.*\*{8}"): + Mf6ListBudget(list_file) + + +def test_mf6listfile_unparseable_later_entry(function_tmpdir): + list_file = function_tmpdir / "gwf.lst" + list_file.write_text( + MF6_BUDGET_TEMPLATE.format(kper=1, chd="0.0000") + + MF6_BUDGET_TEMPLATE.format(kper=2, chd="********") + ) + mflist = Mf6ListBudget(list_file) + inc, cum = mflist.get_budget() + assert inc["STO_IN"][0] == pytest.approx(100.0) + assert np.isnan(inc["STO_IN"][1]) + assert np.isnan(cum["CHD_IN"][1]) diff --git a/flopy/utils/mflistfile.py b/flopy/utils/mflistfile.py index 93581e784..1d69b284a 100644 --- a/flopy/utils/mflistfile.py +++ b/flopy/utils/mflistfile.py @@ -684,14 +684,14 @@ def _set_entries(self): raise Exception(f"entries already set:{self.entries}") if not self.idx_map: raise Exception("must call build_index before call set_entries") + ts, sp, seekpoint = self.idx_map[0] try: - incdict, cumdict = self._get_sp( - self.idx_map[0][0], self.idx_map[0][1], self.idx_map[0][2] - ) - except: - raise Exception( - "unable to read budget information from first entry in list file" - ) + incdict, cumdict = self._get_sp(ts, sp, seekpoint) + except Exception as e: + raise ValueError( + "unable to read budget information from first entry " + f"in list file {self.file_name}: {e}" + ) from e self.entries = incdict.keys() null_entries = {} incdict = {} @@ -759,17 +759,24 @@ def _load(self, maxentries=None): return + def _sp_failed(self, msg): + # entries are read from the first budget, so until they are set + # there are no null entries to fall back on + if not self.entries: + raise ValueError(msg) + print(msg) + return self.null_entries + def _get_sp(self, ts, sp, seekpoint): self.f.seek(seekpoint) # --read to the start of the "in" budget information while True: line = self.f.readline() if line == "": - print( + return self._sp_failed( "end of file found while seeking budget " f"information for ts,sp: {ts} {sp}" ) - return self.null_entries # --if there are two '=' in this line, then it is a budget line if len(re.findall(r"=", line)) == 2: @@ -781,27 +788,28 @@ def _get_sp(self, ts, sp, seekpoint): entrydict = {} while True: if line == "": - print( + return self._sp_failed( "end of file found while seeking budget " f"information for ts,sp: {ts} {sp}" ) - return self.null_entries if len(re.findall(r"=", line)) == 2: try: entry, flux, cumu = self._parse_budget_line(line) except Exception: - print("error parsing budget line in ts,sp", ts, sp) - return self.null_entries + return self._sp_failed( + f"error parsing budget line in ts,sp {ts} {sp}: " + f"{line.strip()!r}" + ) if flux is None: - print( - "error casting in flux for", entry, " to float in ts,sp", ts, sp + return self._sp_failed( + f"error casting in flux for {entry} to float " + f"in ts,sp {ts} {sp}: {line.strip()!r}" ) - return self.null_entries if cumu is None: - print( - "error casting in cumu for", entry, " to float in ts,sp", ts, sp + return self._sp_failed( + f"error casting in cumu for {entry} to float " + f"in ts,sp {ts} {sp}: {line.strip()!r}" ) - return self.null_entries if entry.endswith(tag.upper()): if " - " in entry.upper(): key = entry.replace(" ", "") From 35a2d6027d61bc014f5b8f991f5e1354998c266f Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 28 Sep 2026 13:00:31 -0700 Subject: [PATCH 2/3] refactor(mflistfile): clarify budget parsing error messages Name the budget entry by its column key (e.g. FLOW-JA-FACE_OUT), spell out time step and stress period, and use one message for unparseable rate or cumulative values since the offending line is included. Co-Authored-By: Claude Opus 5.5 --- autotest/test_listbudget.py | 3 ++- flopy/utils/mflistfile.py | 30 ++++++++++++------------------ 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/autotest/test_listbudget.py b/autotest/test_listbudget.py index cbc58c5d0..6641e8522 100644 --- a/autotest/test_listbudget.py +++ b/autotest/test_listbudget.py @@ -195,7 +195,8 @@ def test_mf6listfile_unparseable_first_entry(function_tmpdir): MF6_BUDGET_TEMPLATE.format(kper=1, chd="********") + MF6_BUDGET_TEMPLATE.format(kper=2, chd="0.0000") ) - with pytest.raises(ValueError, match=r"first entry.*ts,sp 1 1.*\*{8}"): + match = r"first budget.*CHD_IN at time step 1, stress period 1: .*\*{8}" + with pytest.raises(ValueError, match=match): Mf6ListBudget(list_file) diff --git a/flopy/utils/mflistfile.py b/flopy/utils/mflistfile.py index 1d69b284a..a4e4ff377 100644 --- a/flopy/utils/mflistfile.py +++ b/flopy/utils/mflistfile.py @@ -689,8 +689,7 @@ def _set_entries(self): incdict, cumdict = self._get_sp(ts, sp, seekpoint) except Exception as e: raise ValueError( - "unable to read budget information from first entry " - f"in list file {self.file_name}: {e}" + f"unable to read first budget in list file {self.file_name}: {e}" ) from e self.entries = incdict.keys() null_entries = {} @@ -774,8 +773,8 @@ def _get_sp(self, ts, sp, seekpoint): line = self.f.readline() if line == "": return self._sp_failed( - "end of file found while seeking budget " - f"information for ts,sp: {ts} {sp}" + "end of file found while reading budget " + f"at time step {ts}, stress period {sp}" ) # --if there are two '=' in this line, then it is a budget line @@ -789,26 +788,16 @@ def _get_sp(self, ts, sp, seekpoint): while True: if line == "": return self._sp_failed( - "end of file found while seeking budget " - f"information for ts,sp: {ts} {sp}" + "end of file found while reading budget " + f"at time step {ts}, stress period {sp}" ) if len(re.findall(r"=", line)) == 2: try: entry, flux, cumu = self._parse_budget_line(line) except Exception: return self._sp_failed( - f"error parsing budget line in ts,sp {ts} {sp}: " - f"{line.strip()!r}" - ) - if flux is None: - return self._sp_failed( - f"error casting in flux for {entry} to float " - f"in ts,sp {ts} {sp}: {line.strip()!r}" - ) - if cumu is None: - return self._sp_failed( - f"error casting in cumu for {entry} to float " - f"in ts,sp {ts} {sp}: {line.strip()!r}" + "could not parse budget line at time step " + f"{ts}, stress period {sp}: {line.strip()!r}" ) if entry.endswith(tag.upper()): if " - " in entry.upper(): @@ -826,6 +815,11 @@ def _get_sp(self, ts, sp, seekpoint): else: entrydict[entry] = 0 key = f"{entry}_{tag}" + if flux is None or cumu is None: + return self._sp_failed( + f"could not parse value for {key} at time step {ts}, " + f"stress period {sp}: {line.strip()!r}" + ) incdict[key] = flux cumdict[key] = cumu else: From b3c55a25ee975c37257099ef72e1921284102b4a Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 28 Sep 2026 13:04:48 -0700 Subject: [PATCH 3/3] refactor(mflistfile): consistent list file parsing messages Label budget entries by name and section, e.g. FLOW-JA-FACE (OUT), with summary lines (TOTAL IN, PERCENT DISCREPANCY) unlabeled. Use the same "time step x, stress period y" wording in time summary and index messages, and report the actual line number when a budget header's time step and stress period can't be parsed (previously always 1). Co-Authored-By: Claude Opus 5.5 --- autotest/test_listbudget.py | 2 +- flopy/utils/mflistfile.py | 38 +++++++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/autotest/test_listbudget.py b/autotest/test_listbudget.py index 6641e8522..b0bcdacda 100644 --- a/autotest/test_listbudget.py +++ b/autotest/test_listbudget.py @@ -195,7 +195,7 @@ def test_mf6listfile_unparseable_first_entry(function_tmpdir): MF6_BUDGET_TEMPLATE.format(kper=1, chd="********") + MF6_BUDGET_TEMPLATE.format(kper=2, chd="0.0000") ) - match = r"first budget.*CHD_IN at time step 1, stress period 1: .*\*{8}" + match = r"first budget.*CHD \(IN\) at time step 1, stress period 1: .*\*{8}" with pytest.raises(ValueError, match=match): Mf6ListBudget(list_file) diff --git a/flopy/utils/mflistfile.py b/flopy/utils/mflistfile.py index a4e4ff377..4301c9be9 100644 --- a/flopy/utils/mflistfile.py +++ b/flopy/utils/mflistfile.py @@ -630,20 +630,23 @@ def _build_index(self, maxentries): def _get_index(self, maxentries): # --parse through the file looking for matches and parsing ts and sp idxs = [] - l_count = 1 + l_count = 0 while True: seekpoint = self.f.tell() line = self.f.readline() + l_count += 1 if line == "": break if self.budgetkey in line: for _ in range(self.tssp_lines): line = self.f.readline() + l_count += 1 try: ts, sp = get_ts_sp(line) - except: + except Exception: print( - "unable to cast ts,sp on line number", l_count, " line: ", line + "could not parse time step and stress period " + f"on line {l_count}: {line.strip()!r}" ) break @@ -799,6 +802,7 @@ def _get_sp(self, ts, sp, seekpoint): "could not parse budget line at time step " f"{ts}, stress period {sp}: {line.strip()!r}" ) + label = entry if entry.endswith(tag.upper()): if " - " in entry.upper(): key = entry.replace(" ", "") @@ -815,9 +819,10 @@ def _get_sp(self, ts, sp, seekpoint): else: entrydict[entry] = 0 key = f"{entry}_{tag}" + label = f"{label} ({tag})" if flux is None or cumu is None: return self._sp_failed( - f"could not parse value for {key} at time step {ts}, " + f"could not parse value for {label} at time step {ts}, " f"stress period {sp}: {line.strip()!r}" ) incdict[key] = flux @@ -858,8 +863,8 @@ def _get_totim(self, ts, sp, seekpoint): ihead += 1 if line == "": print( - "end of file found while seeking budget " - f"information for ts,sp: {ts} {sp}" + "end of file found while reading time summary " + f"at time step {ts}, stress period {sp}" ) return np.nan, np.nan, np.nan elif ( @@ -875,28 +880,37 @@ def _get_totim(self, ts, sp, seekpoint): translen = self._parse_time_line(line) line = self.f.readline() if translen is None: - print("error parsing translen for ts,sp", ts, sp) + print( + f"could not parse transport step length at time step {ts}, " + f"stress period {sp}" + ) return np.nan, np.nan, np.nan tslen = self._parse_time_line(line) if tslen is None: - print("error parsing tslen for ts,sp", ts, sp) + print( + f"could not parse time step length at time step {ts}, " + f"stress period {sp}" + ) return np.nan, np.nan, np.nan sptim = self._parse_time_line(self.f.readline()) if sptim is None: - print("error parsing sptim for ts,sp", ts, sp) + print( + f"could not parse stress period time at time step {ts}, " + f"stress period {sp}" + ) return np.nan, np.nan, np.nan totim = self._parse_time_line(self.f.readline()) if totim is None: - print("error parsing totim for ts,sp", ts, sp) + print(f"could not parse total time at time step {ts}, stress period {sp}") return np.nan, np.nan, np.nan return tslen, sptim, totim def _parse_time_line(self, line): if line == "": - print("end of file found while parsing time information") + print("end of file found while reading time summary") return None try: time_str = line[self.time_line_idx :] @@ -912,7 +926,7 @@ def _parse_time_line(self, line): idx = 0 tval = float(raw[idx]) except: - print("error parsing tslen information: ", time_str) + print(f"could not parse time value: {line.strip()!r}") return None return tval