facloc 0.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.
- facloc/__init__.py +36 -0
- facloc/__main__.py +7 -0
- facloc/_version.py +1 -0
- facloc/brute.py +111 -0
- facloc/cli.py +221 -0
- facloc/distance.py +116 -0
- facloc/errors.py +17 -0
- facloc/exact.py +202 -0
- facloc/heuristics.py +494 -0
- facloc/io.py +162 -0
- facloc/lagrangian.py +82 -0
- facloc/models.py +486 -0
- facloc/problem.py +214 -0
- facloc/py.typed +0 -0
- facloc/solution.py +356 -0
- facloc-0.1.0.dist-info/METADATA +294 -0
- facloc-0.1.0.dist-info/RECORD +21 -0
- facloc-0.1.0.dist-info/WHEEL +5 -0
- facloc-0.1.0.dist-info/entry_points.txt +2 -0
- facloc-0.1.0.dist-info/licenses/LICENSE +21 -0
- facloc-0.1.0.dist-info/top_level.txt +1 -0
facloc/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""facloc - facility location-allocation models for public-service planning.
|
|
2
|
+
|
|
3
|
+
p-median, p-center, maximal covering (MCLP), location set covering (LSCP) and the
|
|
4
|
+
capacitated p-median, solved by numpy heuristics (greedy + Teitz-Bart vertex
|
|
5
|
+
substitution, greedy + swap, greedy set cover), by exhaustive enumeration for small
|
|
6
|
+
instances, or exactly as integer programmes through the optional PuLP/HiGHS extra.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._version import __version__
|
|
10
|
+
from .distance import distance_matrix, euclidean_matrix, haversine_matrix, manhattan_matrix
|
|
11
|
+
from .errors import FaclocError, InfeasibleError, SolverUnavailableError
|
|
12
|
+
from .exact import solver_available
|
|
13
|
+
from .models import capacitated_p_median, lscp, mclp, p_center, p_median, pmedian_lower_bound
|
|
14
|
+
from .problem import Problem
|
|
15
|
+
from .solution import Solution, evaluate
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"FaclocError",
|
|
19
|
+
"InfeasibleError",
|
|
20
|
+
"Problem",
|
|
21
|
+
"Solution",
|
|
22
|
+
"SolverUnavailableError",
|
|
23
|
+
"__version__",
|
|
24
|
+
"capacitated_p_median",
|
|
25
|
+
"distance_matrix",
|
|
26
|
+
"euclidean_matrix",
|
|
27
|
+
"evaluate",
|
|
28
|
+
"haversine_matrix",
|
|
29
|
+
"lscp",
|
|
30
|
+
"manhattan_matrix",
|
|
31
|
+
"mclp",
|
|
32
|
+
"p_center",
|
|
33
|
+
"p_median",
|
|
34
|
+
"pmedian_lower_bound",
|
|
35
|
+
"solver_available",
|
|
36
|
+
]
|
facloc/__main__.py
ADDED
facloc/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
facloc/brute.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Exhaustive enumeration for small instances (a reference optimum, mainly for testing).
|
|
2
|
+
|
|
3
|
+
The p-facility models enumerate all ``C(m_free, p - n_fixed)`` site combinations; the
|
|
4
|
+
LSCP enumerates combinations by increasing size; the capacitated p-median additionally
|
|
5
|
+
enumerates every single-source assignment. ``max_evaluations`` guards against
|
|
6
|
+
accidentally launching an astronomically long run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import itertools
|
|
12
|
+
import math
|
|
13
|
+
from typing import List, Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .heuristics import KeyFn, better
|
|
18
|
+
|
|
19
|
+
__all__ = ["brute_capacitated", "brute_lscp", "brute_nearest"]
|
|
20
|
+
|
|
21
|
+
DEFAULT_MAX_EVALUATIONS = 2_000_000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _guard(count: int, limit: int) -> None:
|
|
25
|
+
if count > limit:
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"brute force would evaluate {count:,} configurations (limit {limit:,}); "
|
|
28
|
+
"use a heuristic or the exact solver"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def brute_nearest(
|
|
33
|
+
d: np.ndarray,
|
|
34
|
+
keys: KeyFn,
|
|
35
|
+
fixed: Sequence[int],
|
|
36
|
+
free: Sequence[int],
|
|
37
|
+
p: int,
|
|
38
|
+
max_evaluations: int = DEFAULT_MAX_EVALUATIONS,
|
|
39
|
+
) -> Tuple[List[int], Tuple[float, float]]:
|
|
40
|
+
"""Best site set for an objective that depends only on nearest-site distances."""
|
|
41
|
+
k = p - len(fixed)
|
|
42
|
+
free_l = [int(j) for j in free]
|
|
43
|
+
_guard(math.comb(len(free_l), k), max_evaluations)
|
|
44
|
+
base = np.asarray(d[:, list(fixed)].min(axis=1)) if fixed else np.full(d.shape[0], np.inf)
|
|
45
|
+
best: Optional[Tuple[List[int], Tuple[float, float]]] = None
|
|
46
|
+
for combo in itertools.combinations(free_l, k):
|
|
47
|
+
m = np.minimum(base, d[:, list(combo)].min(axis=1)) if combo else base
|
|
48
|
+
k1, k2 = keys(m[:, None])
|
|
49
|
+
key = (float(k1[0]), float(k2[0]))
|
|
50
|
+
if best is None or better(key, best[1]):
|
|
51
|
+
best = (list(fixed) + list(combo), key)
|
|
52
|
+
assert best is not None
|
|
53
|
+
return best
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def brute_lscp(
|
|
57
|
+
cover: np.ndarray,
|
|
58
|
+
fixed: Sequence[int],
|
|
59
|
+
free: Sequence[int],
|
|
60
|
+
max_evaluations: int = DEFAULT_MAX_EVALUATIONS,
|
|
61
|
+
) -> List[int]:
|
|
62
|
+
"""Smallest set of new sites that, with ``fixed``, covers every demand point."""
|
|
63
|
+
n = cover.shape[0]
|
|
64
|
+
fixed_cov = cover[:, list(fixed)].any(axis=1) if fixed else np.zeros(n, dtype=bool)
|
|
65
|
+
free_l = [int(j) for j in free]
|
|
66
|
+
evaluated = 0
|
|
67
|
+
for k in range(len(free_l) + 1):
|
|
68
|
+
evaluated += math.comb(len(free_l), k)
|
|
69
|
+
_guard(evaluated, max_evaluations)
|
|
70
|
+
for combo in itertools.combinations(free_l, k):
|
|
71
|
+
cov = fixed_cov | cover[:, list(combo)].any(axis=1) if combo else fixed_cov
|
|
72
|
+
if cov.all():
|
|
73
|
+
return list(combo)
|
|
74
|
+
raise ValueError("no set of sites covers every demand point")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def brute_capacitated(
|
|
78
|
+
cost: np.ndarray,
|
|
79
|
+
w: np.ndarray,
|
|
80
|
+
cap: np.ndarray,
|
|
81
|
+
fixed: Sequence[int],
|
|
82
|
+
free: Sequence[int],
|
|
83
|
+
p: int,
|
|
84
|
+
max_evaluations: int = DEFAULT_MAX_EVALUATIONS,
|
|
85
|
+
) -> Tuple[List[int], np.ndarray, float]:
|
|
86
|
+
"""Exact capacitated p-median (single source) by full enumeration.
|
|
87
|
+
|
|
88
|
+
Returns ``(sites, allocation, cost)``; raises ``ValueError`` if infeasible.
|
|
89
|
+
"""
|
|
90
|
+
n = cost.shape[0]
|
|
91
|
+
k = p - len(fixed)
|
|
92
|
+
free_l = [int(j) for j in free]
|
|
93
|
+
_guard(math.comb(len(free_l), k) * p**n, max_evaluations)
|
|
94
|
+
eps = 1e-9 * max(1.0, float(w.sum()))
|
|
95
|
+
best: Optional[Tuple[List[int], np.ndarray, float]] = None
|
|
96
|
+
for combo in itertools.combinations(free_l, k):
|
|
97
|
+
cols = list(fixed) + list(combo)
|
|
98
|
+
for assign in itertools.product(range(len(cols)), repeat=n):
|
|
99
|
+
a = np.asarray(assign)
|
|
100
|
+
load = np.bincount(a, weights=w, minlength=len(cols))
|
|
101
|
+
if np.any(load > cap[cols] + eps):
|
|
102
|
+
continue
|
|
103
|
+
alloc = np.asarray(cols)[a]
|
|
104
|
+
total = float(cost[np.arange(n), alloc].sum())
|
|
105
|
+
if not np.isfinite(total):
|
|
106
|
+
continue
|
|
107
|
+
if best is None or total < best[2] - 1e-9 * max(1.0, abs(best[2])):
|
|
108
|
+
best = (cols, alloc, total)
|
|
109
|
+
if best is None:
|
|
110
|
+
raise ValueError("capacitated problem is infeasible")
|
|
111
|
+
return best
|
facloc/cli.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Command-line interface: ``facloc solve`` and ``facloc evaluate``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from . import models
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .errors import FaclocError
|
|
12
|
+
from .io import (
|
|
13
|
+
PointTable,
|
|
14
|
+
read_matrix_csv,
|
|
15
|
+
read_points_csv,
|
|
16
|
+
reorder_matrix,
|
|
17
|
+
write_allocations_csv,
|
|
18
|
+
write_json,
|
|
19
|
+
)
|
|
20
|
+
from .problem import Problem
|
|
21
|
+
from .solution import Solution, evaluate
|
|
22
|
+
|
|
23
|
+
MODELS = ("p-median", "p-center", "mclp", "lscp", "capacitated")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _add_input_args(sp: argparse.ArgumentParser) -> None:
|
|
27
|
+
g = sp.add_argument_group("input")
|
|
28
|
+
g.add_argument("--demand", help="demand points CSV (id,x,y,weight)")
|
|
29
|
+
g.add_argument("--sites", help="candidate sites CSV (id,x,y[,capacity][,existing])")
|
|
30
|
+
g.add_argument("--matrix", help="distance matrix CSV (rows demand ids, columns site ids)")
|
|
31
|
+
g.add_argument(
|
|
32
|
+
"--metric",
|
|
33
|
+
default="euclidean",
|
|
34
|
+
choices=("euclidean", "haversine", "manhattan"),
|
|
35
|
+
help="distance from coordinates when no --matrix (haversine: lon/lat in, metres out)",
|
|
36
|
+
)
|
|
37
|
+
g.add_argument("--existing", default="", help="comma-separated ids of existing sites")
|
|
38
|
+
g.add_argument("--id-col", default="id")
|
|
39
|
+
g.add_argument("--x-col", default="x")
|
|
40
|
+
g.add_argument("--y-col", default="y")
|
|
41
|
+
g.add_argument("--weight-col", default="weight")
|
|
42
|
+
g.add_argument("--capacity-col", default="capacity")
|
|
43
|
+
g.add_argument("--existing-col", default="existing")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _add_output_args(sp: argparse.ArgumentParser) -> None:
|
|
47
|
+
g = sp.add_argument_group("output")
|
|
48
|
+
g.add_argument("--json", dest="json_out", help="write the full solution as JSON")
|
|
49
|
+
g.add_argument("--geojson", help="write spider lines + points as GeoJSON")
|
|
50
|
+
g.add_argument("--allocations", help="write demand -> site allocations as CSV")
|
|
51
|
+
g.add_argument("-q", "--quiet", action="store_true", help="do not print the summary")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
55
|
+
ap = argparse.ArgumentParser(
|
|
56
|
+
prog="facloc",
|
|
57
|
+
description="Facility location-allocation: p-median, p-center, MCLP, LSCP, "
|
|
58
|
+
"capacitated p-median.",
|
|
59
|
+
)
|
|
60
|
+
ap.add_argument("--version", action="version", version=f"facloc {__version__}")
|
|
61
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
62
|
+
|
|
63
|
+
sp = sub.add_parser("solve", help="solve a location model")
|
|
64
|
+
sp.add_argument("model", choices=MODELS)
|
|
65
|
+
sp.add_argument("-p", type=int, help="number of facilities in total (existing included)")
|
|
66
|
+
sp.add_argument("--radius", type=float, help="coverage standard (MCLP, LSCP; reporting)")
|
|
67
|
+
sp.add_argument("--method", default="heuristic", choices=models.METHODS)
|
|
68
|
+
sp.add_argument("--weighted", action="store_true", help="p-center: minimise max w*d")
|
|
69
|
+
sp.add_argument("--bound", action="store_true", help="p-median: Lagrangian lower bound")
|
|
70
|
+
sp.add_argument("--restarts", type=int, default=10, help="extra random starts (heuristic)")
|
|
71
|
+
sp.add_argument("--seed", type=int, default=0, help="random seed for restarts")
|
|
72
|
+
sp.add_argument("--solver", default="auto", choices=("auto", "highs", "cbc"))
|
|
73
|
+
sp.add_argument("--time-limit", type=float, help="exact solver time limit in seconds")
|
|
74
|
+
_add_input_args(sp)
|
|
75
|
+
_add_output_args(sp)
|
|
76
|
+
|
|
77
|
+
ev = sub.add_parser("evaluate", help="measure a given set of open sites")
|
|
78
|
+
ev.add_argument("--open", required=True, help="comma-separated ids of open sites")
|
|
79
|
+
ev.add_argument("--radius", type=float, help="coverage standard for reporting")
|
|
80
|
+
_add_input_args(ev)
|
|
81
|
+
_add_output_args(ev)
|
|
82
|
+
return ap
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _read(args: argparse.Namespace, path: str) -> PointTable:
|
|
86
|
+
return read_points_csv(
|
|
87
|
+
path,
|
|
88
|
+
id_col=args.id_col,
|
|
89
|
+
x_col=args.x_col,
|
|
90
|
+
y_col=args.y_col,
|
|
91
|
+
weight_col=args.weight_col,
|
|
92
|
+
capacity_col=args.capacity_col,
|
|
93
|
+
existing_col=args.existing_col,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def problem_from_args(args: argparse.Namespace) -> Problem:
|
|
98
|
+
"""Build a :class:`Problem` from parsed CLI arguments."""
|
|
99
|
+
if not args.demand and not args.matrix:
|
|
100
|
+
raise ValueError("give --demand (with coordinates) and/or --matrix")
|
|
101
|
+
dem = _read(args, args.demand) if args.demand else None
|
|
102
|
+
sit = _read(args, args.sites) if args.sites else None
|
|
103
|
+
if args.matrix:
|
|
104
|
+
m_rows, m_cols, mat = read_matrix_csv(args.matrix)
|
|
105
|
+
d_ids = dem.ids if dem else m_rows
|
|
106
|
+
s_ids = sit.ids if sit else (dem.ids if dem and m_cols == m_rows else m_cols)
|
|
107
|
+
dist = reorder_matrix(mat, m_rows, m_cols, d_ids, s_ids)
|
|
108
|
+
else:
|
|
109
|
+
if dem is None or dem.xy is None:
|
|
110
|
+
raise ValueError("--demand needs x/y columns when no --matrix is given")
|
|
111
|
+
if sit is not None and sit.xy is None:
|
|
112
|
+
raise ValueError("--sites needs x/y columns when no --matrix is given")
|
|
113
|
+
d_ids = dem.ids
|
|
114
|
+
s_ids = sit.ids if sit else dem.ids
|
|
115
|
+
dist = None
|
|
116
|
+
site_tab = sit if sit is not None else dem
|
|
117
|
+
fixed: List[int] = list(sit.existing) if sit is not None else []
|
|
118
|
+
for tok in (t.strip() for t in args.existing.split(",")):
|
|
119
|
+
if tok:
|
|
120
|
+
if tok not in s_ids:
|
|
121
|
+
raise ValueError(f"--existing: unknown site id {tok!r}")
|
|
122
|
+
fixed.append(s_ids.index(tok))
|
|
123
|
+
caps = site_tab.capacities if site_tab is not None and len(site_tab.ids) == len(s_ids) else None
|
|
124
|
+
weights = dem.weights if dem else None
|
|
125
|
+
dxy = dem.xy if dem else None
|
|
126
|
+
sxy = site_tab.xy if site_tab is not None else None
|
|
127
|
+
if dist is None:
|
|
128
|
+
assert dem is not None and dem.xy is not None
|
|
129
|
+
pr = Problem.from_points(
|
|
130
|
+
dem.xy,
|
|
131
|
+
None if sit is None else sit.xy,
|
|
132
|
+
metric=args.metric,
|
|
133
|
+
weights=weights,
|
|
134
|
+
fixed=fixed,
|
|
135
|
+
capacities=caps,
|
|
136
|
+
demand_ids=d_ids,
|
|
137
|
+
site_ids=s_ids,
|
|
138
|
+
)
|
|
139
|
+
return pr
|
|
140
|
+
return Problem(
|
|
141
|
+
dist,
|
|
142
|
+
weights=weights,
|
|
143
|
+
fixed=fixed,
|
|
144
|
+
capacities=caps,
|
|
145
|
+
demand_ids=d_ids,
|
|
146
|
+
site_ids=s_ids,
|
|
147
|
+
demand_xy=dxy if dxy is not None and len(dxy) == len(d_ids) else None,
|
|
148
|
+
site_xy=sxy if sxy is not None and len(sxy) == len(s_ids) else None,
|
|
149
|
+
lonlat=args.metric == "haversine",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _solve(args: argparse.Namespace, pr: Problem) -> Solution:
|
|
154
|
+
m = args.model
|
|
155
|
+
if m in ("p-median", "p-center", "mclp", "capacitated") and args.p is None:
|
|
156
|
+
raise ValueError(f"{m} needs -p")
|
|
157
|
+
if m in ("mclp", "lscp") and args.radius is None:
|
|
158
|
+
raise ValueError(f"{m} needs --radius")
|
|
159
|
+
common = {"method": args.method, "solver": args.solver, "time_limit": args.time_limit}
|
|
160
|
+
if m == "p-median":
|
|
161
|
+
return models.p_median(
|
|
162
|
+
pr,
|
|
163
|
+
args.p,
|
|
164
|
+
radius=args.radius,
|
|
165
|
+
bound=args.bound,
|
|
166
|
+
restarts=args.restarts,
|
|
167
|
+
seed=args.seed,
|
|
168
|
+
**common,
|
|
169
|
+
)
|
|
170
|
+
if m == "p-center":
|
|
171
|
+
return models.p_center(
|
|
172
|
+
pr,
|
|
173
|
+
args.p,
|
|
174
|
+
weighted=args.weighted,
|
|
175
|
+
radius=args.radius,
|
|
176
|
+
restarts=args.restarts,
|
|
177
|
+
seed=args.seed,
|
|
178
|
+
**common,
|
|
179
|
+
)
|
|
180
|
+
if m == "mclp":
|
|
181
|
+
return models.mclp(
|
|
182
|
+
pr, args.p, args.radius, restarts=args.restarts, seed=args.seed, **common
|
|
183
|
+
)
|
|
184
|
+
if m == "lscp":
|
|
185
|
+
return models.lscp(pr, args.radius, **common)
|
|
186
|
+
return models.capacitated_p_median(
|
|
187
|
+
pr, args.p, radius=args.radius, restarts=args.restarts, seed=args.seed, **common
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _write_outputs(args: argparse.Namespace, sol: Solution) -> None:
|
|
192
|
+
if args.json_out:
|
|
193
|
+
write_json(sol.to_dict(), args.json_out)
|
|
194
|
+
if args.geojson:
|
|
195
|
+
write_json(sol.to_geojson(), args.geojson)
|
|
196
|
+
if args.allocations:
|
|
197
|
+
write_allocations_csv(sol.to_dict(), args.allocations)
|
|
198
|
+
if not args.quiet:
|
|
199
|
+
print(sol.summary())
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
203
|
+
"""Entry point; returns the process exit code."""
|
|
204
|
+
ap = build_parser()
|
|
205
|
+
args = ap.parse_args(argv)
|
|
206
|
+
try:
|
|
207
|
+
pr = problem_from_args(args)
|
|
208
|
+
if args.command == "solve":
|
|
209
|
+
sol = _solve(args, pr)
|
|
210
|
+
else:
|
|
211
|
+
ids = [t.strip() for t in args.open.split(",") if t.strip()]
|
|
212
|
+
sol = evaluate(pr, ids, radius=args.radius)
|
|
213
|
+
_write_outputs(args, sol)
|
|
214
|
+
except (FaclocError, ValueError, KeyError, OSError) as exc:
|
|
215
|
+
print(f"facloc: error: {exc}", file=sys.stderr)
|
|
216
|
+
return 2
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__": # pragma: no cover
|
|
221
|
+
sys.exit(main())
|
facloc/distance.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Distance matrices between demand points and candidate sites.
|
|
2
|
+
|
|
3
|
+
Two metrics are built in:
|
|
4
|
+
|
|
5
|
+
* ``"euclidean"`` - straight-line distance in the units of the input coordinates. Use it with
|
|
6
|
+
projected coordinates (metres in a UTM zone or a national grid), never with raw degrees.
|
|
7
|
+
* ``"haversine"`` - great-circle distance on a sphere, for longitude/latitude in degrees.
|
|
8
|
+
The result is in metres. The default radius is the IUGG mean Earth radius
|
|
9
|
+
R1 = 6 371 008.8 m (Moritz, H. 2000, "Geodetic Reference System 1980",
|
|
10
|
+
*Journal of Geodesy* 74(1), 128-133). The spherical model errs by up to about 0.5 %
|
|
11
|
+
against the ellipsoid, which is far below the error of using straight lines instead of
|
|
12
|
+
travel distances in the first place.
|
|
13
|
+
|
|
14
|
+
* ``"manhattan"`` - rectilinear (L1) distance in input units, a rough proxy for travel on a
|
|
15
|
+
grid street network.
|
|
16
|
+
|
|
17
|
+
For real service planning a network (road or walking) distance matrix is usually better;
|
|
18
|
+
compute it elsewhere (OSRM, pgRouting, a GIS) and pass it to :class:`facloc.Problem` directly.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"EARTH_RADIUS_M",
|
|
29
|
+
"METRICS",
|
|
30
|
+
"distance_matrix",
|
|
31
|
+
"euclidean_matrix",
|
|
32
|
+
"haversine_matrix",
|
|
33
|
+
"manhattan_matrix",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
#: IUGG mean Earth radius R1 in metres (Moritz 2000).
|
|
37
|
+
EARTH_RADIUS_M = 6_371_008.8
|
|
38
|
+
|
|
39
|
+
METRICS = ("euclidean", "haversine", "manhattan")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _as_xy(points: object, name: str) -> np.ndarray:
|
|
43
|
+
arr = np.asarray(points, dtype=float)
|
|
44
|
+
if arr.ndim != 2 or arr.shape[1] != 2:
|
|
45
|
+
raise ValueError(f"{name} must be an (n, 2) array of x/y (or lon/lat) pairs")
|
|
46
|
+
if not np.all(np.isfinite(arr)):
|
|
47
|
+
raise ValueError(f"{name} contains NaN or infinite coordinates")
|
|
48
|
+
return arr
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def euclidean_matrix(origins: object, destinations: object) -> np.ndarray:
|
|
52
|
+
"""Straight-line distances, shape ``(len(origins), len(destinations))``."""
|
|
53
|
+
a = _as_xy(origins, "origins")
|
|
54
|
+
b = _as_xy(destinations, "destinations")
|
|
55
|
+
diff = a[:, None, :] - b[None, :, :]
|
|
56
|
+
return np.asarray(np.sqrt((diff**2).sum(axis=2)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def manhattan_matrix(origins: object, destinations: object) -> np.ndarray:
|
|
60
|
+
"""Rectilinear (L1) distances, shape ``(len(origins), len(destinations))``."""
|
|
61
|
+
a = _as_xy(origins, "origins")
|
|
62
|
+
b = _as_xy(destinations, "destinations")
|
|
63
|
+
return np.asarray(np.abs(a[:, None, :] - b[None, :, :]).sum(axis=2))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def haversine_matrix(
|
|
67
|
+
origins: object, destinations: object, radius: float = EARTH_RADIUS_M
|
|
68
|
+
) -> np.ndarray:
|
|
69
|
+
"""Great-circle distances in metres between lon/lat points given in degrees.
|
|
70
|
+
|
|
71
|
+
Uses the haversine formula
|
|
72
|
+
``d = 2 R asin(sqrt(sin^2(dphi/2) + cos phi1 cos phi2 sin^2(dlambda/2)))``.
|
|
73
|
+
"""
|
|
74
|
+
a = _as_xy(origins, "origins")
|
|
75
|
+
b = _as_xy(destinations, "destinations")
|
|
76
|
+
for arr, name in ((a, "origins"), (b, "destinations")):
|
|
77
|
+
if np.any(np.abs(arr[:, 1]) > 90.0) or np.any(np.abs(arr[:, 0]) > 360.0):
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"{name} are not lon/lat degrees (|lat| must be <= 90); "
|
|
80
|
+
"pass coordinates as (lon, lat) or use metric='euclidean' for projected data"
|
|
81
|
+
)
|
|
82
|
+
lon1 = np.radians(a[:, 0])[:, None]
|
|
83
|
+
lat1 = np.radians(a[:, 1])[:, None]
|
|
84
|
+
lon2 = np.radians(b[:, 0])[None, :]
|
|
85
|
+
lat2 = np.radians(b[:, 1])[None, :]
|
|
86
|
+
h = (
|
|
87
|
+
np.sin((lat2 - lat1) / 2.0) ** 2
|
|
88
|
+
+ np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2.0) ** 2
|
|
89
|
+
)
|
|
90
|
+
return np.asarray(2.0 * radius * np.arcsin(np.sqrt(np.clip(h, 0.0, 1.0))))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def distance_matrix(
|
|
94
|
+
origins: object,
|
|
95
|
+
destinations: Optional[object] = None,
|
|
96
|
+
metric: str = "euclidean",
|
|
97
|
+
) -> np.ndarray:
|
|
98
|
+
"""Distance matrix from ``origins`` (rows) to ``destinations`` (columns).
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
origins, destinations:
|
|
103
|
+
``(n, 2)`` coordinate arrays. For ``metric="haversine"`` the columns are
|
|
104
|
+
longitude, latitude in degrees. ``destinations`` defaults to ``origins``.
|
|
105
|
+
metric:
|
|
106
|
+
``"euclidean"``, ``"haversine"`` or ``"manhattan"``.
|
|
107
|
+
"""
|
|
108
|
+
if destinations is None:
|
|
109
|
+
destinations = origins
|
|
110
|
+
if metric == "euclidean":
|
|
111
|
+
return euclidean_matrix(origins, destinations)
|
|
112
|
+
if metric == "haversine":
|
|
113
|
+
return haversine_matrix(origins, destinations)
|
|
114
|
+
if metric == "manhattan":
|
|
115
|
+
return manhattan_matrix(origins, destinations)
|
|
116
|
+
raise ValueError(f"unknown metric {metric!r}; choose one of {', '.join(METRICS)}")
|
facloc/errors.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Exceptions raised by facloc."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["FaclocError", "InfeasibleError", "SolverUnavailableError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FaclocError(Exception):
|
|
9
|
+
"""Base class for facloc errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InfeasibleError(FaclocError, ValueError):
|
|
13
|
+
"""The instance has no feasible solution (e.g. demand that no site can reach or cover)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SolverUnavailableError(FaclocError, ImportError):
|
|
17
|
+
"""The optional exact solver (PuLP / HiGHS) is not installed."""
|