Coverage for backend/django/diagnostics/parsers.py: 74%

114 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-02-12 01:47 +0000

1""" 

2Simple parsers for IDAES DiagnosticsToolbox raw text output. 

3 

4This file contains functions to extract variable information from the  

5text output of IDAES DiagnosticsToolbox. 

6""" 

7from diagnostics.failure_bundle import VariableOutsideBounds 

8 

9 

10def extract_variables_outside_bounds(text: str | None) -> list[VariableOutsideBounds]: 

11 """ 

12 Main function to extract out-of-bounds variables from toolbox text. 

13  

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 

17  

18 Returns a list of VariableOutsideBounds objects. 

19 """ 

20 if not text: 

21 return [] 

22 

23 results = [] 

24 

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 

31 

32 # Try to parse as a bounds line 

33 parsed = parse_bounds_line(line) 

34 if parsed is not None: 

35 results.append(parsed) 

36 

37 return results 

38 

39 

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])" 

45  

46 Handles both single (') and double (") quotes around the variable name. 

47  

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 

55 

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 = '"' 

64 

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 

67 

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 

71 

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 

75 

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 

87 

88 # Extract the bounds [lb, ub] 

89 lower_bound = None 

90 upper_bound = None 

91 

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 

109 

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 

113 

114 return VariableOutsideBounds( 

115 name=var_name, 

116 value=value, 

117 lower=lower_bound, 

118 upper=upper_bound, 

119 reason="fixed_outside_bounds", 

120 ) 

121 

122 

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 

129  

130 Now supports variables with only one bound set. The missing bound is set to None. 

131  

132 Returns a VariableOutsideBounds if we can parse it, otherwise None. 

133 """ 

134 stripped = line.strip() 

135 

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 

142 

143 left_part, right_part = stripped.split(":", 1) 

144 

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() 

150 

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 

153 

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 

163 

164 # Extract the bounds - now allow missing bounds (None or missing values) 

165 lower_bound = None 

166 upper_bound = None 

167 

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 

192 

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 

196 

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 )