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.
- httk/analyse/__init__.py +26 -0
- httk/analyse/crysviz.py +105 -0
- httk/analyse/generic/__init__.py +5 -0
- httk/analyse/generic/_highs.py +134 -0
- httk/analyse/generic/_simplex.py +282 -0
- httk/analyse/generic/lower_hull.py +352 -0
- httk/analyse/matsci/__init__.py +5 -0
- httk/analyse/matsci/phase_diagrams.py +704 -0
- httk/analyse/py.typed +1 -0
- httk_analyse-2.1.0.dist-info/METADATA +188 -0
- httk_analyse-2.1.0.dist-info/RECORD +14 -0
- httk_analyse-2.1.0.dist-info/WHEEL +5 -0
- httk_analyse-2.1.0.dist-info/licenses/LICENSE +661 -0
- httk_analyse-2.1.0.dist-info/top_level.txt +1 -0
httk/analyse/__init__.py
ADDED
|
@@ -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"]
|
httk/analyse/crysviz.py
ADDED
|
@@ -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,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)
|