Coverage for backend/django/diagnostics/parsers.py: 74%
114 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-02-11 21:43 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-02-11 21:43 +0000
1"""
2Simple parsers for IDAES DiagnosticsToolbox raw text output.
4This file contains functions to extract variable information from the
5text output of IDAES DiagnosticsToolbox.
6"""
7from diagnostics.failure_bundle import VariableOutsideBounds
10def extract_variables_outside_bounds(text: str | None) -> list[VariableOutsideBounds]:
11 """
12 Main function to extract out-of-bounds variables from toolbox text.
14 Goes through each line and tries to parse it as either:
15 1. A "trivially infeasible variable" line
16 2. A "value=X bounds=(lb, ub)" line
18 Returns a list of VariableOutsideBounds objects.
19 """
20 if not text:
21 return []
23 results = []
25 for line in text.splitlines():
26 # Try to parse as a "trivially infeasible" line
27 parsed = parse_infeasible_line(line)
28 if parsed is not None:
29 results.append(parsed)
30 continue
32 # Try to parse as a bounds line
33 parsed = parse_bounds_line(line)
34 if parsed is not None:
35 results.append(parsed)
37 return results
40def parse_infeasible_line(line: str) -> VariableOutsideBounds | None:
41 """
42 Parse a line that looks like:
43 "model contains a trivially infeasible variable 'X' (fixed value 123 outside bounds [0, 1])"
44 "model contains a trivially infeasible variable \"X\" (fixed value 123 outside bounds [0, 1])"
46 Handles both single (') and double (") quotes around the variable name.
48 Returns a VariableOutsideBounds if we can parse it, otherwise None.
49 """
50 line_lower = line.lower()
51 if "trivially infeasible variable" not in line_lower:
52 return None
53 if "outside bounds" not in line_lower:
54 return None
56 # Extract the variable name (it's in single or double quotes)
57 # Try single quotes first
58 first_quote = line.find("'")
59 quote_char = "'"
60 if first_quote == -1: 60 ↛ 62line 60 didn't jump to line 62 because the condition on line 60 was never true
61 # Try double quotes
62 first_quote = line.find('"')
63 quote_char = '"'
65 if first_quote == -1: 65 ↛ 66line 65 didn't jump to line 66 because the condition on line 65 was never true
66 return None
68 second_quote = line.find(quote_char, first_quote + 1)
69 if second_quote == -1: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true
70 return None
72 var_name = line[first_quote + 1 : second_quote].strip()
73 if not var_name: 73 ↛ 74line 73 didn't jump to line 74 because the condition on line 73 was never true
74 return None
76 # Extract the fixed value
77 value = None
78 fixed_pos = line_lower.find("fixed value")
79 if fixed_pos != -1: 79 ↛ 89line 79 didn't jump to line 89 because the condition on line 79 was always true
80 after_fixed = line[fixed_pos + len("fixed value"):].strip()
81 words = after_fixed.split()
82 if words: 82 ↛ 89line 82 didn't jump to line 89 because the condition on line 82 was always true
83 try:
84 value = float(words[0])
85 except ValueError:
86 pass
88 # Extract the bounds [lb, ub]
89 lower_bound = None
90 upper_bound = None
92 bounds_pos = line_lower.find("outside bounds")
93 if bounds_pos != -1: 93 ↛ 111line 93 didn't jump to line 111 because the condition on line 93 was always true
94 bracket_start = line.find("[", bounds_pos)
95 if bracket_start != -1: 95 ↛ 111line 95 didn't jump to line 111 because the condition on line 95 was always true
96 bracket_end = line.find("]", bracket_start)
97 if bracket_end != -1: 97 ↛ 111line 97 didn't jump to line 111 because the condition on line 97 was always true
98 inside_brackets = line[bracket_start + 1 : bracket_end]
99 parts = inside_brackets.split(",")
100 if len(parts) >= 2: 100 ↛ 111line 100 didn't jump to line 111 because the condition on line 100 was always true
101 try:
102 lower_bound = float(parts[0].strip())
103 except ValueError:
104 pass
105 try:
106 upper_bound = float(parts[1].strip())
107 except ValueError:
108 pass
110 # Require both bounds to consider this a valid infeasible line
111 if lower_bound is None or upper_bound is None: 111 ↛ 112line 111 didn't jump to line 112 because the condition on line 111 was never true
112 return None
114 return VariableOutsideBounds(
115 name=var_name,
116 value=value,
117 lower=lower_bound,
118 upper=upper_bound,
119 reason="fixed_outside_bounds",
120 )
123def parse_bounds_line(line: str) -> VariableOutsideBounds | None:
124 """
125 Parse a line that looks like:
126 "v3 (free): value=0.0 bounds=(0, 5)"
127 "v4 (free): value=10.0 bounds=(None, 5)" # upper bound only
128 "v5 (free): value=-1.0 bounds=(0, None)" # lower bound only
130 Now supports variables with only one bound set. The missing bound is set to None.
132 Returns a VariableOutsideBounds if we can parse it, otherwise None.
133 """
134 stripped = line.strip()
136 if "value=" not in stripped:
137 return None
138 if "bounds=(" not in stripped: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 return None
140 if ":" not in stripped: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 return None
143 left_part, right_part = stripped.split(":", 1)
145 # Extract the variable name (before any parenthesis)
146 if "(" in left_part: 146 ↛ 149line 146 didn't jump to line 149 because the condition on line 146 was always true
147 var_name = left_part.split("(")[0].strip()
148 else:
149 var_name = left_part.strip()
151 if not var_name: 151 ↛ 152line 151 didn't jump to line 152 because the condition on line 151 was never true
152 return None
154 # Extract the value
155 value = None
156 if "value=" in right_part: 156 ↛ 165line 156 didn't jump to line 165 because the condition on line 156 was always true
157 after_value = right_part.split("value=")[1].strip()
158 first_word = after_value.split()[0].rstrip(",")
159 try:
160 value = float(first_word)
161 except ValueError:
162 pass
164 # Extract the bounds - now allow missing bounds (None or missing values)
165 lower_bound = None
166 upper_bound = None
168 if "bounds=(" in right_part: 168 ↛ 194line 168 didn't jump to line 194 because the condition on line 168 was always true
169 after_bounds = right_part.split("bounds=(")[1]
170 close_paren = after_bounds.find(")")
171 if close_paren != -1: 171 ↛ 194line 171 didn't jump to line 194 because the condition on line 171 was always true
172 inside_parens = after_bounds[:close_paren]
173 if "," not in inside_parens:
174 return None
175 parts = inside_parens.split(",")
176 if len(parts) >= 1: 176 ↛ 184line 176 didn't jump to line 184 because the condition on line 176 was always true
177 # Try to parse lower bound
178 try:
179 lower_str = parts[0].strip()
180 if lower_str.lower() != "none": 180 ↛ 184line 180 didn't jump to line 184 because the condition on line 180 was always true
181 lower_bound = float(lower_str)
182 except (ValueError, IndexError):
183 pass
184 if len(parts) >= 2: 184 ↛ 194line 184 didn't jump to line 194 because the condition on line 184 was always true
185 # Try to parse upper bound
186 try:
187 upper_str = parts[1].strip()
188 if upper_str.lower() != "none": 188 ↛ 194line 188 didn't jump to line 194 because the condition on line 188 was always true
189 upper_bound = float(upper_str)
190 except (ValueError, IndexError):
191 pass
193 # Accept variables with at least one bound (previously required both)
194 if lower_bound is None and upper_bound is None: 194 ↛ 195line 194 didn't jump to line 195 because the condition on line 194 was never true
195 return None
197 return VariableOutsideBounds(
198 name=var_name,
199 value=value,
200 lower=lower_bound,
201 upper=upper_bound,
202 reason="at_or_outside_bounds",
203 )