arealinterp 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.
- arealinterp/__init__.py +106 -0
- arealinterp/__main__.py +7 -0
- arealinterp/_version.py +1 -0
- arealinterp/areal.py +183 -0
- arealinterp/cli.py +295 -0
- arealinterp/dasymetric.py +502 -0
- arealinterp/diagnostics.py +120 -0
- arealinterp/geometry.py +463 -0
- arealinterp/io.py +169 -0
- arealinterp/overlay.py +219 -0
- arealinterp/py.typed +0 -0
- arealinterp/pycno.py +206 -0
- arealinterp/raster.py +209 -0
- arealinterp-0.1.0.dist-info/METADATA +297 -0
- arealinterp-0.1.0.dist-info/RECORD +19 -0
- arealinterp-0.1.0.dist-info/WHEEL +5 -0
- arealinterp-0.1.0.dist-info/entry_points.txt +2 -0
- arealinterp-0.1.0.dist-info/licenses/LICENSE +21 -0
- arealinterp-0.1.0.dist-info/top_level.txt +1 -0
arealinterp/__init__.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""arealinterp: areal interpolation between incompatible zone systems.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
from arealinterp import area_weighted, read_geojson
|
|
6
|
+
|
|
7
|
+
tracts = read_geojson("tracts.geojson", id_field="GEOID")
|
|
8
|
+
districts = read_geojson("districts.geojson", id_field="code")
|
|
9
|
+
res = area_weighted(tracts, districts, extensive="pop", intensive="income")
|
|
10
|
+
print(res.as_dict("pop"), res.volume["pop"])
|
|
11
|
+
|
|
12
|
+
Methods: areal weighting (Goodchild & Lam 1980), target-density weighting
|
|
13
|
+
(Schroeder 2007), binary / density / fixed-percentage dasymetric mapping
|
|
14
|
+
(Eicher & Brewer 2001; Mennis 2003) and smooth pycnophylactic interpolation
|
|
15
|
+
(Tobler 1979). Coordinates must be in a projected (planar) CRS.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .areal import area_weighted, target_density_weighted, zone_values
|
|
20
|
+
from .dasymetric import (
|
|
21
|
+
UNCOVERED,
|
|
22
|
+
DensityEstimate,
|
|
23
|
+
binary_dasymetric,
|
|
24
|
+
class_key,
|
|
25
|
+
dasymetric,
|
|
26
|
+
estimate_class_densities,
|
|
27
|
+
)
|
|
28
|
+
from .diagnostics import InterpolationResult, VolumeReport, volume_report
|
|
29
|
+
from .geometry import (
|
|
30
|
+
GeometryError,
|
|
31
|
+
box,
|
|
32
|
+
intersection,
|
|
33
|
+
intersection_area,
|
|
34
|
+
make_polygon,
|
|
35
|
+
point_in_region,
|
|
36
|
+
region_area,
|
|
37
|
+
region_from_geojson,
|
|
38
|
+
region_to_geojson,
|
|
39
|
+
)
|
|
40
|
+
from .io import (
|
|
41
|
+
join_csv,
|
|
42
|
+
looks_geographic,
|
|
43
|
+
read_csv_table,
|
|
44
|
+
read_geojson,
|
|
45
|
+
to_feature_collection,
|
|
46
|
+
write_csv,
|
|
47
|
+
write_geojson,
|
|
48
|
+
write_overlay_csv,
|
|
49
|
+
zones_from_geojson,
|
|
50
|
+
)
|
|
51
|
+
from .overlay import Overlay, Zone, get_backend, overlay
|
|
52
|
+
from .pycno import PycnoResult, pycnophylactic, pycnophylactic_interpolate
|
|
53
|
+
from .raster import Grid, Raster, rasterize, read_ascii_grid, write_ascii_grid, zonal_sum
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"__version__",
|
|
57
|
+
# zones and overlay
|
|
58
|
+
"Zone",
|
|
59
|
+
"Overlay",
|
|
60
|
+
"overlay",
|
|
61
|
+
"get_backend",
|
|
62
|
+
# geometry
|
|
63
|
+
"GeometryError",
|
|
64
|
+
"box",
|
|
65
|
+
"make_polygon",
|
|
66
|
+
"region_area",
|
|
67
|
+
"region_from_geojson",
|
|
68
|
+
"region_to_geojson",
|
|
69
|
+
"intersection",
|
|
70
|
+
"intersection_area",
|
|
71
|
+
"point_in_region",
|
|
72
|
+
# methods
|
|
73
|
+
"area_weighted",
|
|
74
|
+
"target_density_weighted",
|
|
75
|
+
"dasymetric",
|
|
76
|
+
"binary_dasymetric",
|
|
77
|
+
"estimate_class_densities",
|
|
78
|
+
"DensityEstimate",
|
|
79
|
+
"UNCOVERED",
|
|
80
|
+
"class_key",
|
|
81
|
+
"pycnophylactic",
|
|
82
|
+
"pycnophylactic_interpolate",
|
|
83
|
+
"PycnoResult",
|
|
84
|
+
# diagnostics
|
|
85
|
+
"InterpolationResult",
|
|
86
|
+
"VolumeReport",
|
|
87
|
+
"volume_report",
|
|
88
|
+
"zone_values",
|
|
89
|
+
# raster
|
|
90
|
+
"Grid",
|
|
91
|
+
"Raster",
|
|
92
|
+
"rasterize",
|
|
93
|
+
"zonal_sum",
|
|
94
|
+
"read_ascii_grid",
|
|
95
|
+
"write_ascii_grid",
|
|
96
|
+
# io
|
|
97
|
+
"read_geojson",
|
|
98
|
+
"zones_from_geojson",
|
|
99
|
+
"write_geojson",
|
|
100
|
+
"to_feature_collection",
|
|
101
|
+
"write_csv",
|
|
102
|
+
"write_overlay_csv",
|
|
103
|
+
"read_csv_table",
|
|
104
|
+
"join_csv",
|
|
105
|
+
"looks_geographic",
|
|
106
|
+
]
|
arealinterp/__main__.py
ADDED
arealinterp/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
arealinterp/areal.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Area-weighted and target-density-weighted interpolation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any, Iterable, Optional, Sequence, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .diagnostics import InterpolationResult, volume_report
|
|
11
|
+
from .geometry import DEFAULT_TOLERANCE
|
|
12
|
+
from .overlay import Overlay, Zone, overlay
|
|
13
|
+
|
|
14
|
+
Names = Union[str, Iterable[str], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _names(v: Names) -> list:
|
|
18
|
+
if v is None:
|
|
19
|
+
return []
|
|
20
|
+
if isinstance(v, str):
|
|
21
|
+
return [v]
|
|
22
|
+
return list(v)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def zone_values(zones: Sequence[Zone], variable: str) -> np.ndarray:
|
|
26
|
+
"""Numeric values of ``variable`` for each zone (missing/blank -> NaN)."""
|
|
27
|
+
out = np.empty(len(zones), dtype=float)
|
|
28
|
+
for k, z in enumerate(zones):
|
|
29
|
+
v: Any = z.properties.get(variable)
|
|
30
|
+
if v is None or (isinstance(v, str) and v.strip() == ""):
|
|
31
|
+
out[k] = math.nan
|
|
32
|
+
continue
|
|
33
|
+
try:
|
|
34
|
+
out[k] = float(v)
|
|
35
|
+
except (TypeError, ValueError) as exc:
|
|
36
|
+
raise ValueError(f"zone {z.id!r}: value {v!r} of {variable!r} is not numeric") from exc
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _check_ids(ov: Overlay, sources: Sequence[Zone], targets: Sequence[Zone]) -> None:
|
|
41
|
+
if len(ov.source_ids) != len(sources) or len(ov.target_ids) != len(targets):
|
|
42
|
+
raise ValueError("the supplied overlay does not match the source/target zones")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def area_weighted(
|
|
46
|
+
sources: Sequence[Zone],
|
|
47
|
+
targets: Sequence[Zone],
|
|
48
|
+
extensive: Names = None,
|
|
49
|
+
intensive: Names = None,
|
|
50
|
+
*,
|
|
51
|
+
allocate_total: bool = False,
|
|
52
|
+
backend: str = "auto",
|
|
53
|
+
tolerance: float = DEFAULT_TOLERANCE,
|
|
54
|
+
precomputed: Optional[Overlay] = None,
|
|
55
|
+
) -> InterpolationResult:
|
|
56
|
+
"""Areal weighting (Goodchild & Lam 1980).
|
|
57
|
+
|
|
58
|
+
Extensive (count) variables::
|
|
59
|
+
|
|
60
|
+
y_t = sum_s y_s * A_st / A_s
|
|
61
|
+
|
|
62
|
+
Intensive (rate/density) variables, as an area-weighted mean over the part
|
|
63
|
+
of ``t`` covered by source zones::
|
|
64
|
+
|
|
65
|
+
y_t = sum_s y_s * A_st / sum_s A_st
|
|
66
|
+
|
|
67
|
+
Here ``A_st`` is the area of ``s ∩ t`` and ``A_s`` the area of ``s``.
|
|
68
|
+
With ``allocate_total=True`` the extensive denominator becomes
|
|
69
|
+
``sum_t A_st`` so that each source's whole value is handed to the targets
|
|
70
|
+
it touches (useful when targets do not quite cover the sources, e.g. a
|
|
71
|
+
coastline digitised differently). Source zones with NaN values contribute
|
|
72
|
+
nothing; targets touched by no source get NaN for intensive variables and
|
|
73
|
+
0 for extensive ones.
|
|
74
|
+
|
|
75
|
+
Reference: Goodchild, M. F. & Lam, N. S.-N. (1980). Areal interpolation:
|
|
76
|
+
a variant of the traditional spatial problem. *Geo-Processing* 1, 297-312.
|
|
77
|
+
"""
|
|
78
|
+
ext = _names(extensive)
|
|
79
|
+
intens = _names(intensive)
|
|
80
|
+
if not ext and not intens:
|
|
81
|
+
raise ValueError("give at least one extensive or intensive variable")
|
|
82
|
+
ov = precomputed or overlay(sources, targets, backend=backend, tolerance=tolerance)
|
|
83
|
+
_check_ids(ov, sources, targets)
|
|
84
|
+
n_t = len(targets)
|
|
85
|
+
si, ti, a = ov.source_index, ov.target_index, ov.area
|
|
86
|
+
result = InterpolationResult([z.id for z in targets], {}, "area_weighted")
|
|
87
|
+
result.details["backend"] = ov.backend
|
|
88
|
+
|
|
89
|
+
if allocate_total:
|
|
90
|
+
denom_s = ov.covered_source_area()
|
|
91
|
+
else:
|
|
92
|
+
denom_s = ov.source_areas
|
|
93
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
94
|
+
w_ext = np.where(denom_s[si] > 0, a / denom_s[si], 0.0)
|
|
95
|
+
|
|
96
|
+
for name in ext:
|
|
97
|
+
y = zone_values(sources, name)
|
|
98
|
+
contrib = np.nan_to_num(y[si]) * w_ext
|
|
99
|
+
est = np.bincount(ti, contrib, minlength=n_t)
|
|
100
|
+
allocated = np.bincount(si, contrib, minlength=len(sources))
|
|
101
|
+
result.values[name] = est
|
|
102
|
+
result.volume[name] = volume_report(name, y, est, allocated)
|
|
103
|
+
if not result.volume[name].preserved(rtol=1e-6):
|
|
104
|
+
result.notes.append(
|
|
105
|
+
f"{name}: {result.volume[name].relative_error:+.3%} of the total did not "
|
|
106
|
+
"reach a target zone (sources not fully covered by targets)"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
for name in intens:
|
|
110
|
+
y = zone_values(sources, name)
|
|
111
|
+
ok = ~np.isnan(y[si])
|
|
112
|
+
num = np.bincount(ti[ok], (y[si] * a)[ok], minlength=n_t)
|
|
113
|
+
den = np.bincount(ti[ok], a[ok], minlength=n_t)
|
|
114
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
115
|
+
result.values[name] = np.where(den > 0, num / den, np.nan)
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def target_density_weighted(
|
|
120
|
+
sources: Sequence[Zone],
|
|
121
|
+
targets: Sequence[Zone],
|
|
122
|
+
extensive: Names,
|
|
123
|
+
target_variable: str,
|
|
124
|
+
*,
|
|
125
|
+
backend: str = "auto",
|
|
126
|
+
tolerance: float = DEFAULT_TOLERANCE,
|
|
127
|
+
precomputed: Optional[Overlay] = None,
|
|
128
|
+
) -> InterpolationResult:
|
|
129
|
+
"""Target-density weighting (TDW; Schroeder 2007).
|
|
130
|
+
|
|
131
|
+
An ancillary count ``z_t`` known for the *target* zones (for example the
|
|
132
|
+
population of the target year) is assumed uniformly spread inside each
|
|
133
|
+
target zone. Each source value is split among its intersections in
|
|
134
|
+
proportion to the ancillary amount they contain::
|
|
135
|
+
|
|
136
|
+
w_st = A_st * z_t / A_t
|
|
137
|
+
y_t = sum_s y_s * w_st / sum_t' w_st'
|
|
138
|
+
|
|
139
|
+
Sources whose intersections all carry ``z = 0`` fall back to areal
|
|
140
|
+
weighting over their covered area (listed in ``details['fallback_sources']``).
|
|
141
|
+
Each source value is fully allocated to the targets it touches.
|
|
142
|
+
|
|
143
|
+
Reference: Schroeder, J. P. (2007). Target-density weighting interpolation
|
|
144
|
+
and uncertainty evaluation for temporal analysis of census data.
|
|
145
|
+
*Geographical Analysis* 39(3), 311-335.
|
|
146
|
+
"""
|
|
147
|
+
ext = _names(extensive)
|
|
148
|
+
if not ext:
|
|
149
|
+
raise ValueError("give at least one extensive variable")
|
|
150
|
+
ov = precomputed or overlay(sources, targets, backend=backend, tolerance=tolerance)
|
|
151
|
+
_check_ids(ov, sources, targets)
|
|
152
|
+
n_s, n_t = len(sources), len(targets)
|
|
153
|
+
si, ti, a = ov.source_index, ov.target_index, ov.area
|
|
154
|
+
z = np.nan_to_num(zone_values(targets, target_variable))
|
|
155
|
+
if np.any(z < 0):
|
|
156
|
+
raise ValueError(f"target variable {target_variable!r} must be non-negative")
|
|
157
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
158
|
+
dens = np.where(ov.target_areas > 0, z / ov.target_areas, 0.0)
|
|
159
|
+
w = a * dens[ti]
|
|
160
|
+
wsum = np.bincount(si, w, minlength=n_s)
|
|
161
|
+
asum = np.bincount(si, a, minlength=n_s)
|
|
162
|
+
fallback = (wsum <= 0) & (asum > 0)
|
|
163
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
164
|
+
share = np.where(
|
|
165
|
+
fallback[si],
|
|
166
|
+
a / np.where(asum[si] > 0, asum[si], 1.0),
|
|
167
|
+
w / np.where(wsum[si] > 0, wsum[si], 1.0),
|
|
168
|
+
)
|
|
169
|
+
result = InterpolationResult([t.id for t in targets], {}, "target_density_weighted")
|
|
170
|
+
result.details["backend"] = ov.backend
|
|
171
|
+
result.details["fallback_sources"] = [sources[int(k)].id for k in np.nonzero(fallback)[0]]
|
|
172
|
+
if result.details["fallback_sources"]:
|
|
173
|
+
result.notes.append(
|
|
174
|
+
f"{len(result.details['fallback_sources'])} source zone(s) overlap only targets "
|
|
175
|
+
f"with {target_variable}=0; areal weighting used for them"
|
|
176
|
+
)
|
|
177
|
+
for name in ext:
|
|
178
|
+
y = zone_values(sources, name)
|
|
179
|
+
contrib = np.nan_to_num(y[si]) * share
|
|
180
|
+
est = np.bincount(ti, contrib, minlength=n_t)
|
|
181
|
+
result.values[name] = est
|
|
182
|
+
result.volume[name] = volume_report(name, y, est, np.bincount(si, contrib, minlength=n_s))
|
|
183
|
+
return result
|
arealinterp/cli.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""Command-line interface: ``arealinterp <command> ...``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .areal import area_weighted, target_density_weighted
|
|
12
|
+
from .dasymetric import Ancillary, binary_dasymetric, dasymetric, estimate_class_densities
|
|
13
|
+
from .diagnostics import InterpolationResult
|
|
14
|
+
from .io import (
|
|
15
|
+
join_csv,
|
|
16
|
+
looks_geographic,
|
|
17
|
+
read_geojson,
|
|
18
|
+
read_geojson_crs,
|
|
19
|
+
write_csv,
|
|
20
|
+
write_geojson,
|
|
21
|
+
write_overlay_csv,
|
|
22
|
+
)
|
|
23
|
+
from .overlay import Zone, overlay
|
|
24
|
+
from .pycno import pycnophylactic, pycnophylactic_interpolate
|
|
25
|
+
from .raster import read_ascii_grid, write_ascii_grid
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _split(values: Optional[Sequence[str]]) -> List[str]:
|
|
29
|
+
out: List[str] = []
|
|
30
|
+
for v in values or []:
|
|
31
|
+
out.extend(x.strip() for x in v.split(",") if x.strip())
|
|
32
|
+
return out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _pairs(values: Optional[Sequence[str]], flag: str) -> Dict[str, float]:
|
|
36
|
+
out: Dict[str, float] = {}
|
|
37
|
+
for item in _split(values):
|
|
38
|
+
if "=" not in item:
|
|
39
|
+
raise SystemExit(f"arealinterp: {flag} expects CLASS=VALUE, got {item!r}")
|
|
40
|
+
k, v = item.split("=", 1)
|
|
41
|
+
try:
|
|
42
|
+
out[k.strip()] = float(v)
|
|
43
|
+
except ValueError:
|
|
44
|
+
raise SystemExit(f"arealinterp: {flag}: {v!r} is not a number") from None
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _load(path: str, id_field: Optional[str], role: str) -> List[Zone]:
|
|
49
|
+
zones = read_geojson(path, id_field)
|
|
50
|
+
if not zones:
|
|
51
|
+
raise SystemExit(f"arealinterp: no polygon features in {role} file {path}")
|
|
52
|
+
if looks_geographic(zones):
|
|
53
|
+
print(
|
|
54
|
+
f"arealinterp: warning: {role} coordinates look like longitude/latitude; "
|
|
55
|
+
"areas need a projected CRS (reproject first)",
|
|
56
|
+
file=sys.stderr,
|
|
57
|
+
)
|
|
58
|
+
return zones
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _add_common(p: argparse.ArgumentParser, target: bool = True) -> None:
|
|
62
|
+
p.add_argument("source", help="source zones (GeoJSON)")
|
|
63
|
+
if target:
|
|
64
|
+
p.add_argument("target", help="target zones (GeoJSON)")
|
|
65
|
+
p.add_argument("--source-id", help="source id property (default: feature id or index)")
|
|
66
|
+
p.add_argument("--target-id", help="target id property (default: feature id or index)")
|
|
67
|
+
p.add_argument("--join", metavar="CSV", help="join source attributes from a CSV file")
|
|
68
|
+
p.add_argument("--join-id", help="id column of --join (default: --source-id or 'id')")
|
|
69
|
+
p.add_argument("-o", "--output", help="output .geojson or .csv (default: CSV on stdout)")
|
|
70
|
+
p.add_argument(
|
|
71
|
+
"--backend", default="auto", choices=["auto", "python", "shapely"], help="geometry engine"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _add_extensive(p: argparse.ArgumentParser, required: bool = True) -> None:
|
|
76
|
+
p.add_argument(
|
|
77
|
+
"-e",
|
|
78
|
+
"--extensive",
|
|
79
|
+
action="append",
|
|
80
|
+
required=required,
|
|
81
|
+
help="extensive (count) variable; repeat or comma-separate",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
86
|
+
ap = argparse.ArgumentParser(
|
|
87
|
+
prog="arealinterp",
|
|
88
|
+
description="Areal interpolation between incompatible zone systems.",
|
|
89
|
+
)
|
|
90
|
+
ap.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
91
|
+
sub = ap.add_subparsers(dest="command", required=True)
|
|
92
|
+
|
|
93
|
+
p = sub.add_parser("overlay", help="write the source x target intersection-area table")
|
|
94
|
+
_add_common(p)
|
|
95
|
+
|
|
96
|
+
p = sub.add_parser("areal", help="area-weighted interpolation (Goodchild & Lam 1980)")
|
|
97
|
+
_add_common(p)
|
|
98
|
+
_add_extensive(p, required=False)
|
|
99
|
+
p.add_argument("-i", "--intensive", action="append", help="intensive (rate) variable")
|
|
100
|
+
p.add_argument(
|
|
101
|
+
"--allocate-total",
|
|
102
|
+
action="store_true",
|
|
103
|
+
help="hand each source's whole value to the targets it touches",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
p = sub.add_parser("tdw", help="target-density weighting (Schroeder 2007)")
|
|
107
|
+
_add_common(p)
|
|
108
|
+
_add_extensive(p)
|
|
109
|
+
p.add_argument("--target-weight", required=True, help="ancillary count on the target zones")
|
|
110
|
+
|
|
111
|
+
p = sub.add_parser("dasymetric", help="dasymetric mapping with land cover")
|
|
112
|
+
_add_common(p)
|
|
113
|
+
_add_extensive(p)
|
|
114
|
+
anc = p.add_mutually_exclusive_group(required=True)
|
|
115
|
+
anc.add_argument("--raster", help="land-cover raster (ESRI ASCII grid .asc)")
|
|
116
|
+
anc.add_argument("--landcover", help="land-cover polygons (GeoJSON)")
|
|
117
|
+
p.add_argument("--class-field", default="class", help="class property of --landcover")
|
|
118
|
+
how = p.add_mutually_exclusive_group(required=True)
|
|
119
|
+
how.add_argument("--inhabited", action="append", help="binary method: inhabited classes")
|
|
120
|
+
how.add_argument("--density", action="append", help="relative density CLASS=VALUE")
|
|
121
|
+
how.add_argument("--percent", action="append", help="fixed share CLASS=VALUE")
|
|
122
|
+
how.add_argument(
|
|
123
|
+
"--estimate", action="store_true", help="estimate densities by percent-cover sampling"
|
|
124
|
+
)
|
|
125
|
+
p.add_argument("--threshold", type=float, default=0.8, help="sampling threshold (0-1]")
|
|
126
|
+
p.add_argument("--preset", action="append", help="preset density CLASS=VALUE (--estimate)")
|
|
127
|
+
p.add_argument("--default-weight", type=float, default=0.0, help="weight of unlisted classes")
|
|
128
|
+
p.add_argument("--surface", help="write the dasymetric surface (raster input) to .asc")
|
|
129
|
+
|
|
130
|
+
p = sub.add_parser("pycno", help="pycnophylactic interpolation (Tobler 1979)")
|
|
131
|
+
_add_common(p, target=False)
|
|
132
|
+
p.add_argument("target", nargs="?", help="target zones (GeoJSON); optional")
|
|
133
|
+
_add_extensive(p)
|
|
134
|
+
p.add_argument("--cell-size", type=float, required=True, help="grid cell size (map units)")
|
|
135
|
+
p.add_argument("--max-iter", type=int, default=1000)
|
|
136
|
+
p.add_argument("--tolerance", type=float, default=1e-4)
|
|
137
|
+
p.add_argument("--relaxation", type=float, default=0.5)
|
|
138
|
+
p.add_argument("--boundary", choices=["neumann", "zero"], default="neumann")
|
|
139
|
+
p.add_argument("--surface", help="write the surface of the first variable to .asc")
|
|
140
|
+
return ap
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _write_result(
|
|
144
|
+
result: InterpolationResult, targets: List[Zone], args: argparse.Namespace
|
|
145
|
+
) -> None:
|
|
146
|
+
out = args.output
|
|
147
|
+
if out is None:
|
|
148
|
+
import csv
|
|
149
|
+
import math
|
|
150
|
+
|
|
151
|
+
w = csv.writer(sys.stdout, lineterminator="\n")
|
|
152
|
+
names = list(result.values)
|
|
153
|
+
w.writerow(["id", *names])
|
|
154
|
+
for row in result.records():
|
|
155
|
+
w.writerow(
|
|
156
|
+
[row["id"]]
|
|
157
|
+
+ [
|
|
158
|
+
"" if isinstance(row[n], float) and math.isnan(row[n]) else row[n]
|
|
159
|
+
for n in names
|
|
160
|
+
]
|
|
161
|
+
)
|
|
162
|
+
elif Path(out).suffix.lower() == ".csv":
|
|
163
|
+
write_csv(out, result)
|
|
164
|
+
else:
|
|
165
|
+
write_geojson(out, targets, result, crs=read_geojson_crs(args.target))
|
|
166
|
+
print(result.summary(), file=sys.stderr)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
170
|
+
args = build_parser().parse_args(argv)
|
|
171
|
+
try:
|
|
172
|
+
return _run(args)
|
|
173
|
+
except (ValueError, KeyError, OSError, ImportError) as exc:
|
|
174
|
+
print(f"arealinterp: error: {exc}", file=sys.stderr)
|
|
175
|
+
return 2
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run(args: argparse.Namespace) -> int:
|
|
179
|
+
sources = _load(args.source, args.source_id, "source")
|
|
180
|
+
if args.join:
|
|
181
|
+
missing = join_csv(sources, args.join, args.join_id or args.source_id or "id")
|
|
182
|
+
if missing:
|
|
183
|
+
print(
|
|
184
|
+
f"arealinterp: warning: {len(missing)} source zone(s) without a CSV row",
|
|
185
|
+
file=sys.stderr,
|
|
186
|
+
)
|
|
187
|
+
targets = _load(args.target, args.target_id, "target") if args.target else None
|
|
188
|
+
ext = _split(getattr(args, "extensive", None))
|
|
189
|
+
|
|
190
|
+
if args.command == "overlay":
|
|
191
|
+
assert targets is not None
|
|
192
|
+
ov = overlay(sources, targets, backend=args.backend)
|
|
193
|
+
if args.output:
|
|
194
|
+
write_overlay_csv(args.output, ov)
|
|
195
|
+
else:
|
|
196
|
+
import csv
|
|
197
|
+
|
|
198
|
+
w = csv.DictWriter(
|
|
199
|
+
sys.stdout,
|
|
200
|
+
fieldnames=["source_id", "target_id", "area", "source_share", "target_share"],
|
|
201
|
+
lineterminator="\n",
|
|
202
|
+
)
|
|
203
|
+
w.writeheader()
|
|
204
|
+
for r in ov.records():
|
|
205
|
+
w.writerow(r)
|
|
206
|
+
print(f"overlay: {len(ov.area)} intersections ({ov.backend})", file=sys.stderr)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
if args.command == "areal":
|
|
210
|
+
assert targets is not None
|
|
211
|
+
res = area_weighted(
|
|
212
|
+
sources,
|
|
213
|
+
targets,
|
|
214
|
+
ext,
|
|
215
|
+
_split(args.intensive),
|
|
216
|
+
allocate_total=args.allocate_total,
|
|
217
|
+
backend=args.backend,
|
|
218
|
+
)
|
|
219
|
+
elif args.command == "tdw":
|
|
220
|
+
assert targets is not None
|
|
221
|
+
res = target_density_weighted(
|
|
222
|
+
sources, targets, ext, args.target_weight, backend=args.backend
|
|
223
|
+
)
|
|
224
|
+
elif args.command == "dasymetric":
|
|
225
|
+
assert targets is not None
|
|
226
|
+
anc: Ancillary
|
|
227
|
+
if args.raster:
|
|
228
|
+
anc = read_ascii_grid(args.raster)
|
|
229
|
+
else:
|
|
230
|
+
anc = read_geojson(args.landcover)
|
|
231
|
+
kw: Dict[str, Any] = {"class_field": args.class_field, "backend": args.backend}
|
|
232
|
+
if args.inhabited:
|
|
233
|
+
res = binary_dasymetric(sources, targets, anc, ext, _split(args.inhabited), **kw)
|
|
234
|
+
elif args.density:
|
|
235
|
+
res = dasymetric(
|
|
236
|
+
sources,
|
|
237
|
+
targets,
|
|
238
|
+
anc,
|
|
239
|
+
ext,
|
|
240
|
+
densities=_pairs(args.density, "--density"),
|
|
241
|
+
default_weight=args.default_weight,
|
|
242
|
+
**kw,
|
|
243
|
+
)
|
|
244
|
+
elif args.percent:
|
|
245
|
+
res = dasymetric(
|
|
246
|
+
sources,
|
|
247
|
+
targets,
|
|
248
|
+
anc,
|
|
249
|
+
ext,
|
|
250
|
+
percentages=_pairs(args.percent, "--percent"),
|
|
251
|
+
default_weight=args.default_weight,
|
|
252
|
+
**kw,
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
est = estimate_class_densities(
|
|
256
|
+
sources,
|
|
257
|
+
anc,
|
|
258
|
+
ext[0],
|
|
259
|
+
threshold=args.threshold,
|
|
260
|
+
presets=_pairs(args.preset, "--preset"),
|
|
261
|
+
**kw,
|
|
262
|
+
)
|
|
263
|
+
for c, d in est.densities.items():
|
|
264
|
+
print(
|
|
265
|
+
f"density {c}: {d:.6g} ({est.origin[c]}, n={est.sampled[c]})", file=sys.stderr
|
|
266
|
+
)
|
|
267
|
+
res = dasymetric(sources, targets, anc, ext, densities=est.densities, **kw)
|
|
268
|
+
if args.surface:
|
|
269
|
+
surf = res.details.get("surfaces", {}).get(ext[0])
|
|
270
|
+
if surf is None:
|
|
271
|
+
raise ValueError("--surface needs --raster ancillary data")
|
|
272
|
+
write_ascii_grid(args.surface, surf)
|
|
273
|
+
else: # pycno
|
|
274
|
+
kw = {
|
|
275
|
+
"max_iter": args.max_iter,
|
|
276
|
+
"tolerance": args.tolerance,
|
|
277
|
+
"relaxation": args.relaxation,
|
|
278
|
+
"boundary": args.boundary,
|
|
279
|
+
}
|
|
280
|
+
if targets is None:
|
|
281
|
+
pr = pycnophylactic(sources, ext[0], args.cell_size, **kw)
|
|
282
|
+
if not args.surface:
|
|
283
|
+
raise ValueError("pycno without target zones needs --surface")
|
|
284
|
+
write_ascii_grid(args.surface, pr.surface)
|
|
285
|
+
print(
|
|
286
|
+
f"pycnophylactic: {pr.iterations} iterations, converged={pr.converged}; "
|
|
287
|
+
f"{pr.volume}",
|
|
288
|
+
file=sys.stderr,
|
|
289
|
+
)
|
|
290
|
+
return 0
|
|
291
|
+
res = pycnophylactic_interpolate(sources, targets, ext, args.cell_size, **kw)
|
|
292
|
+
if args.surface:
|
|
293
|
+
write_ascii_grid(args.surface, res.details["pycno"][ext[0]].surface)
|
|
294
|
+
_write_result(res, targets, args)
|
|
295
|
+
return 0
|