nkde 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.
nkde/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """nkde: network kernel density estimation along street networks.
2
+
3
+ Estimate the density of events (crashes, crimes, shops, ...) *along* a street
4
+ network rather than across the plane: lixelisation, event snapping, the simple,
5
+ discontinuous and continuous network KDE of Okabe & Sugihara, adaptive
6
+ bandwidths, spatio-temporal TNKDE, planar KDE for comparison, the network
7
+ K-function and hot-spot ranking.
8
+ """
9
+
10
+ from .estimators import METHODS, NKDEResult, abramson_bandwidths, event_kernel, nkde
11
+ from .hotspots import HotspotRanking, hotspot_clusters, monte_carlo_pvalues, rank_hotspots
12
+ from .io import (
13
+ events_from_geojson,
14
+ network_from_geojson,
15
+ read_lines,
16
+ read_points,
17
+ result_to_geojson,
18
+ )
19
+ from .kernels import KERNELS, get_kernel
20
+ from .kfunction import KFunctionResult, network_k_function, pairwise_network_distances
21
+ from .network import Events, Lixels, Network
22
+ from .planar import planar_kde
23
+ from .projection import UTMZone, lonlat_to_utm, utm_to_lonlat, utm_transformers
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "KERNELS",
29
+ "METHODS",
30
+ "Events",
31
+ "HotspotRanking",
32
+ "KFunctionResult",
33
+ "Lixels",
34
+ "NKDEResult",
35
+ "Network",
36
+ "UTMZone",
37
+ "__version__",
38
+ "abramson_bandwidths",
39
+ "event_kernel",
40
+ "events_from_geojson",
41
+ "get_kernel",
42
+ "hotspot_clusters",
43
+ "lonlat_to_utm",
44
+ "monte_carlo_pvalues",
45
+ "network_from_geojson",
46
+ "network_k_function",
47
+ "nkde",
48
+ "pairwise_network_distances",
49
+ "planar_kde",
50
+ "rank_hotspots",
51
+ "read_lines",
52
+ "read_points",
53
+ "result_to_geojson",
54
+ "utm_to_lonlat",
55
+ "utm_transformers",
56
+ ]
nkde/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow ``python -m nkde``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ sys.exit(main())
nkde/cli.py ADDED
@@ -0,0 +1,225 @@
1
+ """Command line interface: ``nkde run`` and ``nkde kfunction``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ import time
9
+ from typing import List, Optional, Sequence
10
+
11
+ import numpy as np
12
+
13
+ from . import __version__
14
+ from .estimators import METHODS, nkde
15
+ from .hotspots import rank_hotspots
16
+ from .io import network_from_geojson, read_lines, read_points
17
+ from .kernels import KERNELS
18
+ from .kfunction import network_k_function
19
+ from .projection import utm_transformers
20
+
21
+
22
+ def _parse_times(text: str) -> np.ndarray:
23
+ """``start:stop:step`` (stop inclusive) or a comma separated list."""
24
+ if ":" in text:
25
+ parts = [float(p) for p in text.split(":")]
26
+ if len(parts) != 3 or parts[2] <= 0:
27
+ raise argparse.ArgumentTypeError("use start:stop:step with a positive step")
28
+ start, stop, step = parts
29
+ n = int(np.floor((stop - start) / step + 1e-9)) + 1
30
+ return start + step * np.arange(max(n, 1))
31
+ return np.array([float(p) for p in text.split(",") if p.strip()])
32
+
33
+
34
+ def _load(args: argparse.Namespace): # type: ignore[no-untyped-def]
35
+ forward = inverse = None
36
+ if args.lonlat:
37
+ lines, _ = read_lines(args.roads)
38
+ allc = np.vstack(lines)
39
+ forward, inverse, zone = utm_transformers(allc[:, 0], allc[:, 1])
40
+ print(
41
+ f"projected to UTM zone {zone.zone}{'S' if zone.south else 'N'} (EPSG:{zone.epsg})",
42
+ file=sys.stderr,
43
+ )
44
+ net = network_from_geojson(
45
+ args.roads,
46
+ transform=forward,
47
+ id_field=args.id_field,
48
+ split_at_intersections=args.split_crossings,
49
+ )
50
+ xy, t, w, _ = read_points(
51
+ args.events,
52
+ forward,
53
+ time_field=getattr(args, "time_field", None),
54
+ weight_field=getattr(args, "weight_field", None),
55
+ )
56
+ events = net.snap(xy, times=t, weights=w, max_distance=args.max_snap)
57
+ dropped = len(xy) - len(events)
58
+ print(
59
+ f"network: {net.n_nodes} nodes, {net.n_edges} edges, {net.total_length:.1f} m; "
60
+ f"events: {len(events)} snapped"
61
+ + (f", {dropped} dropped (> --max-snap)" if dropped else ""),
62
+ file=sys.stderr,
63
+ )
64
+ return net, events, inverse
65
+
66
+
67
+ def _cmd_run(args: argparse.Namespace) -> int:
68
+ net, events, inverse = _load(args)
69
+ if len(events) == 0:
70
+ print("error: no events left after snapping", file=sys.stderr)
71
+ return 2
72
+ times = None
73
+ if args.times is not None:
74
+ times = _parse_times(args.times)
75
+ t0 = time.perf_counter()
76
+ res = nkde(
77
+ net,
78
+ events,
79
+ args.bw,
80
+ lixel_length=args.lixel,
81
+ kernel=args.kernel,
82
+ method=args.method,
83
+ adaptive=args.adaptive,
84
+ trim_bw=args.trim_bw,
85
+ max_depth=args.max_depth,
86
+ times=times,
87
+ time_bw=args.time_bw,
88
+ )
89
+ elapsed = time.perf_counter() - t0
90
+ res.to_geojson(args.output, inverse_transform=inverse, precision=args.precision)
91
+ print(
92
+ f"{args.method} NKDE ({args.kernel}, bw={args.bw:g} m"
93
+ f"{', adaptive' if args.adaptive else ''}) on {len(res.lixels)} lixels "
94
+ f"in {elapsed:.2f} s -> {args.output}",
95
+ file=sys.stderr,
96
+ )
97
+ if args.top:
98
+ rk = rank_hotspots(res, top=args.top)
99
+ print("rank\tlixel\tedge\tdensity")
100
+ for r, li, d in zip(rk.rank.tolist(), rk.lixel.tolist(), rk.density.tolist()):
101
+ print(f"{r}\t{li}\t{int(res.lixels.edge[li])}\t{d:.6g}")
102
+ return 0
103
+
104
+
105
+ def _cmd_kfunction(args: argparse.Namespace) -> int:
106
+ net, events, _ = _load(args)
107
+ t = np.arange(args.step, args.tmax + 1e-9, args.step)
108
+ res = network_k_function(net, events, t, n_simulations=args.nsim, seed=args.seed)
109
+ rows = []
110
+ for i in range(len(t)):
111
+ row = {"t": float(t[i]), "k": float(res.k[i])}
112
+ if res.lower is not None and res.upper is not None and res.mean is not None:
113
+ row.update(
114
+ lower=float(res.lower[i]), upper=float(res.upper[i]), mean=float(res.mean[i])
115
+ )
116
+ rows.append(row)
117
+ text: str
118
+ if args.output and args.output.lower().endswith(".json"):
119
+ text = json.dumps(rows, indent=1)
120
+ else:
121
+ cols = list(rows[0].keys())
122
+ text = "\n".join([",".join(cols)] + [",".join(f"{r[c]:.6g}" for c in cols) for r in rows])
123
+ if args.output:
124
+ with open(args.output, "w", encoding="utf-8") as fh:
125
+ fh.write(text + "\n")
126
+ print(f"K-function at {len(t)} distances -> {args.output}", file=sys.stderr)
127
+ else:
128
+ print(text)
129
+ return 0
130
+
131
+
132
+ def _common(p: argparse.ArgumentParser) -> None:
133
+ p.add_argument("roads", help="GeoJSON line layer (projected metres unless --lonlat)")
134
+ p.add_argument("events", help="GeoJSON point layer")
135
+ p.add_argument(
136
+ "--lonlat",
137
+ action="store_true",
138
+ help="inputs are WGS84 lon/lat: project to UTM (output written back in lon/lat)",
139
+ )
140
+ p.add_argument(
141
+ "--max-snap",
142
+ type=float,
143
+ default=None,
144
+ help="drop events farther than this from the network (m)",
145
+ )
146
+ p.add_argument(
147
+ "--id-field", default=None, help="road property copied to the output as source_id"
148
+ )
149
+ p.add_argument(
150
+ "--split-crossings",
151
+ action="store_true",
152
+ help="planarise: add junctions where road lines cross without a shared vertex",
153
+ )
154
+
155
+
156
+ def build_parser() -> argparse.ArgumentParser:
157
+ parser = argparse.ArgumentParser(
158
+ prog="nkde", description="Network kernel density estimation along street networks."
159
+ )
160
+ parser.add_argument("--version", action="version", version=f"nkde {__version__}")
161
+ sub = parser.add_subparsers(dest="command", required=True)
162
+
163
+ run = sub.add_parser("run", help="compute a (temporal) network KDE on lixels")
164
+ _common(run)
165
+ run.add_argument("--bw", type=float, required=True, help="bandwidth in metres")
166
+ run.add_argument(
167
+ "--lixel", type=float, default=10.0, help="lixel length in metres (default 10)"
168
+ )
169
+ run.add_argument("--method", choices=METHODS, default="continuous")
170
+ run.add_argument("--kernel", choices=KERNELS, default="quartic")
171
+ run.add_argument("--adaptive", action="store_true", help="Abramson adaptive bandwidths")
172
+ run.add_argument(
173
+ "--trim-bw", type=float, default=None, help="cap for adaptive bandwidths (default 3*bw)"
174
+ )
175
+ run.add_argument("--max-depth", type=int, default=None, help="max junctions crossed per branch")
176
+ run.add_argument("--weight-field", default=None, help="event property holding weights")
177
+ run.add_argument(
178
+ "--time-field", default=None, help="event property holding times (number or ISO date)"
179
+ )
180
+ run.add_argument(
181
+ "--time-bw",
182
+ type=float,
183
+ default=None,
184
+ help="temporal bandwidth (same unit as times; days for ISO dates)",
185
+ )
186
+ run.add_argument(
187
+ "--times", default=None, help="evaluation times: start:stop:step or comma list"
188
+ )
189
+ run.add_argument("--precision", type=int, default=3, help="decimals of output coordinates")
190
+ run.add_argument("--top", type=int, default=0, help="print the N highest-density lixels")
191
+ run.add_argument("-o", "--output", required=True, help="output GeoJSON path")
192
+ run.set_defaults(func=_cmd_run)
193
+
194
+ kf = sub.add_parser("kfunction", help="global network K-function with CSR envelopes")
195
+ _common(kf)
196
+ kf.add_argument("--tmax", type=float, required=True, help="largest distance (m)")
197
+ kf.add_argument("--step", type=float, required=True, help="distance step (m)")
198
+ kf.add_argument("--nsim", type=int, default=99, help="number of CSR simulations")
199
+ kf.add_argument("--seed", type=int, default=None)
200
+ kf.add_argument("-o", "--output", default=None, help="CSV or .json path (stdout if omitted)")
201
+ kf.set_defaults(func=_cmd_kfunction)
202
+ return parser
203
+
204
+
205
+ def main(argv: Optional[Sequence[str]] = None) -> int:
206
+ parser = build_parser()
207
+ args = parser.parse_args(list(argv) if argv is not None else None)
208
+ if args.command == "run":
209
+ if (args.times is None) != (args.time_field is None) or (
210
+ args.times is not None and args.time_bw is None
211
+ ):
212
+ parser.error("temporal NKDE needs --time-field, --times and --time-bw together")
213
+ try:
214
+ code: int = args.func(args)
215
+ except (ValueError, OSError) as exc:
216
+ print(f"error: {exc}", file=sys.stderr)
217
+ return 1
218
+ return code
219
+
220
+
221
+ if __name__ == "__main__": # pragma: no cover
222
+ sys.exit(main())
223
+
224
+
225
+ __all__: List[str] = ["main", "build_parser"]