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