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 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
@@ -0,0 +1,7 @@
1
+ """``python -m facloc``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
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."""