httk-analyse 2.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ """Analysis capabilities for the httk namespace package."""
2
+
3
+ from httk.core import register_citation
4
+
5
+ register_citation(
6
+ applies_to="Numerical analysis uses NumPy",
7
+ references={
8
+ "authors": (
9
+ {"name": "Charles R. Harris"},
10
+ {"name": "K. Jarrod Millman"},
11
+ {"name": "Stéfan J. van der Walt"},
12
+ {"name": "and others"},
13
+ ),
14
+ "title": "Array programming with NumPy",
15
+ "journal": "Nature",
16
+ "volume": "585",
17
+ "pages": "357-362",
18
+ "year": "2020",
19
+ "doi": "10.1038/s41586-020-2649-2",
20
+ "bib_type": "article",
21
+ },
22
+ )
23
+
24
+ from . import crysviz, generic, matsci
25
+
26
+ __all__ = ["crysviz", "generic", "matsci"]
@@ -0,0 +1,105 @@
1
+ """Open httk structures in the CrysViz viewer."""
2
+
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path, PureWindowsPath
6
+ from typing import Any
7
+
8
+ from httk.core import register_citation, save
9
+
10
+
11
+ def _import_crysviz() -> Any:
12
+ try:
13
+ import crysviz # type: ignore[import-not-found]
14
+ except ImportError as exc:
15
+ raise ImportError("httk.analyse.crysviz requires crysviz; install httk-analyse[crysviz]") from exc
16
+ return crysviz
17
+
18
+
19
+ def _safe_filename(name: str) -> bool:
20
+ """Return whether ``name`` is a non-path filename usable on every platform."""
21
+ return bool(
22
+ name
23
+ and name not in {".", ".."}
24
+ and not any(character in name for character in "/\\")
25
+ and not Path(name).is_absolute()
26
+ and not PureWindowsPath(name).is_absolute()
27
+ and not PureWindowsPath(name).drive
28
+ and all(character.isprintable() for character in name)
29
+ )
30
+
31
+
32
+ def to_payload(
33
+ structure: Any,
34
+ *,
35
+ name: str | None = None,
36
+ format: str = "vasp-poscar",
37
+ ) -> Any:
38
+ """Serialize an httk structure as an in-memory CrysViz payload.
39
+
40
+ :param structure: Structure to serialize.
41
+ :param name: Optional filename for the payload, without a path.
42
+ :param format: Serialization format, either ``"vasp-poscar"`` or ``"cif"``.
43
+ :return: A CrysViz payload containing the serialized structure.
44
+ :raises ImportError: If CrysViz is not installed.
45
+ :raises ValueError: If ``format`` is not supported.
46
+ """
47
+ if format not in {"vasp-poscar", "cif"}:
48
+ raise ValueError("format must be 'vasp-poscar' or 'cif'")
49
+
50
+ crysviz = _import_crysviz()
51
+ suffix = ".vasp" if format == "vasp-poscar" else ".cif"
52
+ if name:
53
+ if not _safe_filename(name):
54
+ raise ValueError("name must be a nonempty filename without a path")
55
+ filename = name
56
+ else:
57
+ formula = getattr(structure, "formula", None)
58
+ formula_text = str(formula) if formula is not None else ""
59
+ filename = formula_text if _safe_filename(formula_text) else "structure"
60
+ if not filename.casefold().endswith(suffix):
61
+ filename += suffix
62
+
63
+ with tempfile.TemporaryDirectory() as directory:
64
+ destination = Path(directory) / f"structure{suffix}"
65
+ save(structure, destination, format=format)
66
+ text = destination.read_text(encoding="utf-8")
67
+ return crysviz.Payload(filename, text)
68
+
69
+
70
+ def show(*structures: Any, **viewer_kwargs: Any) -> Any:
71
+ r"""Open structures in CrysViz and return when its window is ready.
72
+
73
+ :param \*structures: CrysViz payloads, source paths, or httk structures to display.
74
+ :param \**viewer_kwargs: Keyword arguments forwarded to ``crysviz.show``.
75
+ :return: The ready CrysViz viewer, which also supports the context-manager protocol.
76
+ :raises ImportError: If CrysViz is not installed.
77
+
78
+ The call is non-blocking after the window is ready. Call ``viewer.wait()`` to
79
+ block until the window closes.
80
+ """
81
+ crysviz = _import_crysviz()
82
+ sources: list[Any] = []
83
+ for structure in structures:
84
+ if isinstance(structure, (crysviz.Payload, str, os.PathLike)):
85
+ sources.append(structure)
86
+ else:
87
+ sources.append(to_payload(structure))
88
+
89
+ register_citation(
90
+ applies_to="Structure visualisation uses CrysViz",
91
+ references={
92
+ "authors": (
93
+ {"name": "Florian Trybel"},
94
+ {"name": "Abhijith S Parackal"},
95
+ {"name": "Oscar Bulancea-Lindvall"},
96
+ {"name": "Henricus R.A. ten Eikelder"},
97
+ {"name": "Rickard Armiento"},
98
+ ),
99
+ "title": "CrysViz - Crystal Structure Visualisation & Analysis",
100
+ "url": "https://github.com/CrysViz/crysviz",
101
+ "year": "2026",
102
+ "bib_type": "misc",
103
+ },
104
+ )
105
+ return crysviz.show(sources, **viewer_kwargs)
@@ -0,0 +1,5 @@
1
+ """Generic numerical analysis independent of a scientific domain."""
2
+
3
+ from .lower_hull import LowerConvexHull
4
+
5
+ __all__ = ["LowerConvexHull"]
@@ -0,0 +1,134 @@
1
+ """Optional HiGHS candidate-basis provider for lower-hull mixtures."""
2
+
3
+ import importlib
4
+ from collections.abc import Sequence
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+
9
+ from ._simplex import _PIVOT_TOLERANCE, _basis_is_well_conditioned, _scaled_independent_equalities, _simplex_iterations
10
+
11
+ highspy: Any = importlib.import_module("highspy")
12
+
13
+
14
+ class _HighsMixtureSolver:
15
+ """Reuse one HiGHS model while retaining the local simplex as final arbiter."""
16
+
17
+ def __init__(self, points: Sequence[Sequence[float]], values: Sequence[float]) -> None:
18
+ self._points = points
19
+ self._values = values
20
+ self._enabled = set(range(len(points)))
21
+ self._model: Any = None
22
+ try:
23
+ coordinates = np.asarray(points, dtype=np.float64)
24
+ origin = coordinates[0]
25
+ relative = coordinates - origin
26
+ scale = np.max(np.abs(relative), axis=0)
27
+ scale[scale == 0.0] = 1.0
28
+ matrix = np.vstack(((relative / scale).T, np.ones(len(points))))
29
+ costs = np.asarray(values, dtype=np.float64)
30
+ shifted_costs = costs - np.min(costs)
31
+ if not (np.all(np.isfinite(matrix)) and np.all(np.isfinite(shifted_costs))):
32
+ raise FloatingPointError
33
+ except (FloatingPointError, OverflowError, ValueError):
34
+ return
35
+
36
+ model = highspy.Highs()
37
+ for name, value in {
38
+ "output_flag": False,
39
+ "solver": "simplex",
40
+ "threads": 1,
41
+ "primal_feasibility_tolerance": 1e-10,
42
+ "dual_feasibility_tolerance": 1e-10,
43
+ "small_matrix_value": 1e-12,
44
+ }.items():
45
+ if model.setOptionValue(name, value) == highspy.HighsStatus.kError:
46
+ return
47
+ row_count, column_count = matrix.shape
48
+ lp = highspy.HighsLp()
49
+ lp.num_col_, lp.num_row_ = column_count, row_count
50
+ lp.col_cost_ = shifted_costs
51
+ lp.col_lower_ = np.zeros(column_count)
52
+ lp.col_upper_ = np.full(column_count, highspy.kHighsInf)
53
+ rhs = np.r_[np.zeros(row_count - 1), 1.0]
54
+ lp.row_lower_ = rhs
55
+ lp.row_upper_ = rhs
56
+ lp.a_matrix_.format_ = highspy.MatrixFormat.kRowwise
57
+ lp.a_matrix_.start_ = np.arange(row_count + 1) * column_count
58
+ lp.a_matrix_.index_ = np.tile(np.arange(column_count), row_count)
59
+ lp.a_matrix_.value_ = matrix.ravel()
60
+ if model.passModel(lp) == highspy.HighsStatus.kError:
61
+ return
62
+ self._model = model
63
+ self._origin = origin
64
+ self._scale = scale
65
+
66
+ def solve(
67
+ self,
68
+ indices: Sequence[int],
69
+ origin: tuple[float, ...],
70
+ offsets: Sequence[float],
71
+ ) -> tuple[float, tuple[float, ...]] | None:
72
+ """Return a locally-polished mixture, or ``None`` for the reference solver."""
73
+ if not indices or self._model is None:
74
+ return None
75
+ try:
76
+ model = self._model
77
+ current = set(indices)
78
+ for index in current ^ self._enabled:
79
+ upper = highspy.kHighsInf if index in current else 0.0
80
+ if model.changeColBounds(index, 0.0, upper) == highspy.HighsStatus.kError:
81
+ self._model = None
82
+ return None
83
+ self._enabled = current
84
+ rhs = np.r_[
85
+ (np.asarray(offsets) + (np.asarray(origin) - self._origin)) / self._scale,
86
+ 1.0,
87
+ ]
88
+ if not np.all(np.isfinite(rhs)):
89
+ return None
90
+ rows = np.arange(len(rhs))
91
+ if model.changeRowsBounds(len(rhs), rows, rhs, rhs) == highspy.HighsStatus.kError:
92
+ self._model = None
93
+ return None
94
+ if model.run() == highspy.HighsStatus.kError:
95
+ self._model = None
96
+ return None
97
+ if model.getModelStatus() != highspy.HighsModelStatus.kOptimal:
98
+ return None
99
+
100
+ matrix = np.asarray(
101
+ [[self._points[index][axis] - origin[axis] for index in indices] for axis in range(len(origin))]
102
+ + [[1.0] * len(indices)],
103
+ dtype=np.float64,
104
+ )
105
+ target = np.asarray([*offsets, 1.0], dtype=np.float64)
106
+ costs = np.asarray([self._values[index] for index in indices], dtype=np.float64)
107
+ baseline = float(np.min(costs))
108
+ objective = costs - baseline
109
+ if not (np.all(np.isfinite(matrix)) and np.all(np.isfinite(objective))):
110
+ return None
111
+ coefficients, target, full_coefficients, full_target = _scaled_independent_equalities(
112
+ matrix, target, _PIVOT_TOLERANCE
113
+ )
114
+ statuses = model.getBasis().col_status
115
+ basis = [
116
+ position for position, index in enumerate(indices) if statuses[index] == highspy.HighsBasisStatus.kBasic
117
+ ]
118
+ if len(basis) != coefficients.shape[0] or not _basis_is_well_conditioned(
119
+ coefficients, basis, _PIVOT_TOLERANCE
120
+ ):
121
+ return None
122
+ _, weights = _simplex_iterations(
123
+ coefficients,
124
+ target,
125
+ objective,
126
+ basis,
127
+ _PIVOT_TOLERANCE,
128
+ local_objective_tolerance=True,
129
+ )
130
+ if np.any(np.abs(full_coefficients @ weights - full_target) > 100.0 * _PIVOT_TOLERANCE):
131
+ return None
132
+ return baseline + float((costs - baseline) @ weights), tuple(float(weight) for weight in weights)
133
+ except (FloatingPointError, OverflowError, ValueError, RuntimeError, np.linalg.LinAlgError):
134
+ return None
@@ -0,0 +1,282 @@
1
+ """Private deterministic solver for small equality-constrained linear programs."""
2
+
3
+ import math
4
+ from collections.abc import Sequence
5
+
6
+ import numpy as np
7
+
8
+ _PIVOT_TOLERANCE = 1e-11
9
+
10
+
11
+ def _reduced_cost_tolerances(matrix: np.ndarray, costs: np.ndarray, multipliers: np.ndarray) -> np.ndarray:
12
+ """Return local float64 roundoff bounds for reduced-cost subtractions."""
13
+ multiplication_terms = np.sum(np.abs(matrix) * np.abs(multipliers)[:, None], axis=0)
14
+ return 32.0 * np.finfo(np.float64).eps * (np.abs(costs) + multiplication_terms)
15
+
16
+
17
+ class _LPInfeasibleError(ValueError):
18
+ """The equality-constrained linear program has no feasible point."""
19
+
20
+
21
+ class _LPUnboundedError(ValueError):
22
+ """The equality-constrained linear program is unbounded below."""
23
+
24
+
25
+ def _basis_is_well_conditioned(matrix: np.ndarray, basis: Sequence[int], tolerance: float) -> bool:
26
+ """Return whether a candidate basis is numerically safe enough to solve."""
27
+ if not basis:
28
+ return True
29
+ condition = float(np.linalg.cond(matrix[:, basis]))
30
+ limit = 1.0 / max(tolerance, 100.0 * np.finfo(np.float64).eps)
31
+ return math.isfinite(condition) and condition <= limit
32
+
33
+
34
+ def _matrix_rank(matrix: np.ndarray, threshold: float) -> int:
35
+ """Return the SVD rank against one absolute singular-value threshold."""
36
+ if matrix.size == 0:
37
+ return 0
38
+ singular_values = np.linalg.svd(matrix, compute_uv=False)
39
+ return int(np.count_nonzero(singular_values > threshold))
40
+
41
+
42
+ def _scaled_independent_equalities(
43
+ matrix: np.ndarray,
44
+ rhs: np.ndarray,
45
+ tolerance: float,
46
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
47
+ """Scale equalities, reject inconsistency, and retain independent rows."""
48
+ if matrix.shape[0] == 0:
49
+ return matrix.copy(), rhs.copy(), matrix.copy(), rhs.copy()
50
+
51
+ if matrix.shape[1] == 0:
52
+ coefficient_sizes = np.zeros(matrix.shape[0], dtype=np.float64)
53
+ else:
54
+ coefficient_sizes = np.max(np.abs(matrix), axis=1)
55
+ row_sizes = np.maximum(coefficient_sizes, np.abs(rhs))
56
+ row_sizes[row_sizes == 0.0] = 1.0
57
+ scaled_matrix = matrix / row_sizes[:, None]
58
+ scaled_rhs = rhs / row_sizes
59
+ augmented = np.column_stack((scaled_matrix, scaled_rhs))
60
+ singular_values = np.linalg.svd(augmented, compute_uv=False)
61
+ largest = float(singular_values[0])
62
+ rank_threshold = tolerance * max(1.0, largest)
63
+ coefficient_rank = _matrix_rank(scaled_matrix, rank_threshold)
64
+ augmented_rank = int(np.count_nonzero(singular_values > rank_threshold))
65
+ if augmented_rank > coefficient_rank:
66
+ raise _LPInfeasibleError("linear program is infeasible")
67
+
68
+ selected: list[int] = []
69
+ selected_rank = 0
70
+ for row in range(scaled_matrix.shape[0]):
71
+ trial = scaled_matrix[[*selected, row], :]
72
+ trial_rank = _matrix_rank(trial, rank_threshold)
73
+ if trial_rank > selected_rank:
74
+ selected.append(row)
75
+ selected_rank = trial_rank
76
+ if selected_rank == coefficient_rank:
77
+ break
78
+ if selected_rank != coefficient_rank:
79
+ raise RuntimeError("could not select independent equality constraints")
80
+ return (
81
+ scaled_matrix[selected, :],
82
+ scaled_rhs[selected],
83
+ scaled_matrix,
84
+ scaled_rhs,
85
+ )
86
+
87
+
88
+ def _simplex_iterations(
89
+ matrix: np.ndarray,
90
+ rhs: np.ndarray,
91
+ costs: np.ndarray,
92
+ basis: list[int],
93
+ tolerance: float,
94
+ *,
95
+ local_objective_tolerance: bool = False,
96
+ ) -> tuple[list[int], np.ndarray]:
97
+ """Run revised-simplex pivots from a feasible basis using Bland's rule."""
98
+ row_count, variable_count = matrix.shape
99
+ if row_count == 0:
100
+ if np.any(costs < 0.0):
101
+ raise _LPUnboundedError("linear program is unbounded below")
102
+ return basis, np.zeros(variable_count, dtype=np.float64)
103
+
104
+ max_iterations = max(10_000, 100 * (row_count + variable_count))
105
+ for _ in range(max_iterations):
106
+ basis_matrix = matrix[:, basis]
107
+ try:
108
+ basic_values = np.linalg.solve(basis_matrix, rhs)
109
+ multipliers = np.linalg.solve(basis_matrix.T, costs[basis])
110
+ except np.linalg.LinAlgError as exc:
111
+ raise RuntimeError("simplex basis became singular") from exc
112
+
113
+ small_negative = (basic_values < 0.0) & (np.abs(basic_values) <= tolerance)
114
+ basic_values[small_negative] = 0.0
115
+ if np.any(basic_values < -tolerance):
116
+ raise RuntimeError("simplex basis lost primal feasibility")
117
+
118
+ reduced_costs = costs - matrix.T @ multipliers
119
+ reduced_costs[basis] = 0.0
120
+ reduced_cost_tolerances: float | np.ndarray
121
+ if local_objective_tolerance:
122
+ reduced_cost_tolerances = _reduced_cost_tolerances(matrix, costs, multipliers)
123
+ else:
124
+ reduced_cost_tolerances = tolerance
125
+ entering_candidates = np.flatnonzero(reduced_costs < -reduced_cost_tolerances).tolist()
126
+ if not entering_candidates:
127
+ solution = np.zeros(variable_count, dtype=np.float64)
128
+ solution[basis] = basic_values
129
+ return basis, solution
130
+
131
+ pivoted = False
132
+ for entering in entering_candidates:
133
+ direction = np.linalg.solve(basis_matrix, matrix[:, entering])
134
+ eligible = [row for row in range(row_count) if direction[row] > tolerance]
135
+ if not eligible:
136
+ raise _LPUnboundedError("linear program is unbounded below")
137
+
138
+ ratios = {row: basic_values[row] / direction[row] for row in eligible}
139
+ minimum = min(ratios.values())
140
+ machine_tolerance = 16.0 * np.finfo(np.float64).eps
141
+ minimizers = [
142
+ row
143
+ for row in eligible
144
+ if abs(ratios[row] - minimum) <= machine_tolerance * max(1.0, abs(minimum), abs(ratios[row]))
145
+ ]
146
+ for leaving_row in sorted(minimizers, key=basis.__getitem__):
147
+ candidate_basis = basis.copy()
148
+ candidate_basis[leaving_row] = entering
149
+ if not _basis_is_well_conditioned(matrix, candidate_basis, tolerance):
150
+ continue
151
+ basis = candidate_basis
152
+ pivoted = True
153
+ break
154
+ if pivoted:
155
+ break
156
+ if not pivoted:
157
+ raise RuntimeError("simplex found no numerically safe pivot")
158
+
159
+ raise RuntimeError("simplex iteration limit exceeded")
160
+
161
+
162
+ def _solve_equality_lp(
163
+ costs: Sequence[float],
164
+ matrix: Sequence[Sequence[float]],
165
+ rhs: Sequence[float],
166
+ *,
167
+ pivot_tolerance: float = _PIVOT_TOLERANCE,
168
+ ) -> tuple[float, tuple[float, ...]]:
169
+ """Minimize ``costs @ x`` subject to ``matrix @ x == rhs`` and ``x >= 0``.
170
+
171
+ This deterministic dense two-phase revised simplex max-scales equality rows,
172
+ uses SVD rank cleanup, and starts from artificial variables. Both entering and
173
+ genuinely tied leaving variables follow Bland's anti-cycling rule. Candidate
174
+ pivots whose bases are too ill-conditioned are skipped.
175
+
176
+ The caller may express coordinate constraints relative to an origin. Phase-two
177
+ reduced-cost tests use local ``float64`` roundoff bounds for each objective
178
+ subtraction.
179
+
180
+ :param costs: Objective coefficients to minimize.
181
+ :param matrix: Equality-constraint coefficient rows.
182
+ :param rhs: Target values for the equality constraints.
183
+ :param pivot_tolerance: Positive threshold for rank, feasibility, and pivot tests.
184
+ :return: The minimum objective value and corresponding non-negative variable values.
185
+
186
+ :raises _LPInfeasibleError: If the equality constraints cannot be satisfied.
187
+ :raises _LPUnboundedError: If the objective is unbounded below.
188
+ """
189
+ tolerance = float(pivot_tolerance)
190
+ if not math.isfinite(tolerance) or tolerance <= 0.0:
191
+ raise ValueError("pivot_tolerance must be a finite positive number")
192
+
193
+ objective = np.asarray(costs, dtype=np.float64)
194
+ coefficients = np.asarray(matrix, dtype=np.float64)
195
+ target = np.asarray(rhs, dtype=np.float64)
196
+ if objective.ndim != 1 or coefficients.ndim != 2 or target.ndim != 1:
197
+ raise ValueError("costs and rhs must be vectors and matrix must be two-dimensional")
198
+ row_count, variable_count = coefficients.shape
199
+ if objective.shape != (variable_count,) or target.shape != (row_count,):
200
+ raise ValueError("linear-program dimensions do not agree")
201
+ if not (np.all(np.isfinite(objective)) and np.all(np.isfinite(coefficients)) and np.all(np.isfinite(target))):
202
+ raise ValueError("linear-program inputs must be finite")
203
+ coefficients, target, scaled_coefficients, scaled_target = _scaled_independent_equalities(
204
+ coefficients,
205
+ target,
206
+ tolerance,
207
+ )
208
+ row_count = coefficients.shape[0]
209
+ if variable_count == 0:
210
+ return 0.0, ()
211
+
212
+ negative_rhs = target < 0.0
213
+ coefficients = coefficients.copy()
214
+ target = target.copy()
215
+ coefficients[negative_rhs] *= -1.0
216
+ target[negative_rhs] *= -1.0
217
+
218
+ artificial = np.eye(row_count, dtype=np.float64)
219
+ phase_one_matrix = np.concatenate((coefficients, artificial), axis=1)
220
+ phase_one_costs = np.concatenate(
221
+ (
222
+ np.zeros(variable_count, dtype=np.float64),
223
+ np.ones(row_count, dtype=np.float64),
224
+ )
225
+ )
226
+ basis = list(range(variable_count, variable_count + row_count))
227
+ basis, phase_one_solution = _simplex_iterations(
228
+ phase_one_matrix,
229
+ target,
230
+ phase_one_costs,
231
+ basis,
232
+ tolerance,
233
+ )
234
+ artificial_sum = float(np.sum(phase_one_solution[variable_count:]))
235
+ if artificial_sum > tolerance:
236
+ raise _LPInfeasibleError("linear program is infeasible")
237
+
238
+ # A zero artificial basic variable either pivots onto an original column or marks a
239
+ # redundant equality.
240
+ while any(index >= variable_count for index in basis):
241
+ artificial_row = min(
242
+ (row for row, index in enumerate(basis) if index >= variable_count),
243
+ key=basis.__getitem__,
244
+ )
245
+ basis_matrix = phase_one_matrix[:, basis]
246
+ tableau_original = np.linalg.solve(basis_matrix, phase_one_matrix[:, :variable_count])
247
+ basis_set = set(basis)
248
+ entering_candidates = [
249
+ index
250
+ for index in range(variable_count)
251
+ if index not in basis_set and abs(tableau_original[artificial_row, index]) > tolerance
252
+ ]
253
+ pivoted = False
254
+ for entering in entering_candidates:
255
+ candidate_basis = basis.copy()
256
+ candidate_basis[artificial_row] = entering
257
+ if not _basis_is_well_conditioned(phase_one_matrix, candidate_basis, tolerance):
258
+ continue
259
+ basis = candidate_basis
260
+ pivoted = True
261
+ break
262
+ if pivoted:
263
+ continue
264
+ if entering_candidates:
265
+ raise RuntimeError("simplex found no numerically safe artificial-variable pivot")
266
+ phase_one_matrix = np.delete(phase_one_matrix, artificial_row, axis=0)
267
+ target = np.delete(target, artificial_row)
268
+ del basis[artificial_row]
269
+
270
+ phase_two_matrix = phase_one_matrix[:, :variable_count]
271
+ _, solution = _simplex_iterations(
272
+ phase_two_matrix,
273
+ target,
274
+ objective,
275
+ basis,
276
+ tolerance,
277
+ local_objective_tolerance=True,
278
+ )
279
+ residual = scaled_coefficients @ solution - scaled_target
280
+ if np.any(np.abs(residual) > 100.0 * tolerance):
281
+ raise _LPInfeasibleError("linear program did not reach a feasible point")
282
+ return float(objective @ solution), tuple(float(value) for value in solution)