sdg11 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.
sdg11/__init__.py ADDED
@@ -0,0 +1,73 @@
1
+ """sdg11 - UN Sustainable Development Goal 11 urban indicators from open data.
2
+
3
+ Indicators follow the published UN-Habitat / WHO metadata and the Degree of
4
+ Urbanisation manual. This is an independent open-source implementation, not an
5
+ official UN product.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ from ._result import IndicatorResult
13
+ from .air_quality import (
14
+ WHO_2021_ANNUAL,
15
+ annual_mean,
16
+ assign_nearest_station,
17
+ population_weighted_concentration,
18
+ )
19
+ from .citations import ALL_CITATIONS, Citation
20
+ from .components import label
21
+ from .config import run_config
22
+ from .degurba import (
23
+ RURAL,
24
+ URBAN_CENTRE,
25
+ URBAN_CLUSTER,
26
+ DegurbaResult,
27
+ classify_degurba,
28
+ )
29
+ from .geometry import project_lonlat
30
+ from .housing import is_overcrowded, slum_proportion
31
+ from .land import built_up_area_km2, land_consumption, land_consumption_from_grids
32
+ from .network import WalkNetwork
33
+ from .open_space import (
34
+ average_open_space_share,
35
+ open_space_access,
36
+ open_space_share,
37
+ polygons_area_km2,
38
+ )
39
+ from .report import Report, report
40
+ from .transport import capacity_class, public_transport_access
41
+
42
+ __all__ = [
43
+ "__version__",
44
+ "ALL_CITATIONS",
45
+ "Citation",
46
+ "DegurbaResult",
47
+ "IndicatorResult",
48
+ "Report",
49
+ "RURAL",
50
+ "URBAN_CENTRE",
51
+ "URBAN_CLUSTER",
52
+ "WHO_2021_ANNUAL",
53
+ "WalkNetwork",
54
+ "annual_mean",
55
+ "assign_nearest_station",
56
+ "average_open_space_share",
57
+ "built_up_area_km2",
58
+ "capacity_class",
59
+ "classify_degurba",
60
+ "is_overcrowded",
61
+ "label",
62
+ "land_consumption",
63
+ "land_consumption_from_grids",
64
+ "open_space_access",
65
+ "open_space_share",
66
+ "polygons_area_km2",
67
+ "population_weighted_concentration",
68
+ "project_lonlat",
69
+ "public_transport_access",
70
+ "report",
71
+ "run_config",
72
+ "slum_proportion",
73
+ ]
sdg11/_result.py ADDED
@@ -0,0 +1,117 @@
1
+ """Result container shared by every indicator function."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import numpy as np
11
+
12
+ from .citations import Citation
13
+
14
+
15
+ def _jsonable(value: Any) -> Any:
16
+ """Convert numpy scalars/arrays and nested containers into JSON-safe values."""
17
+ if isinstance(value, Citation):
18
+ return value.to_dict()
19
+ if isinstance(value, dict):
20
+ return {str(k): _jsonable(v) for k, v in value.items()}
21
+ if isinstance(value, (list, tuple)):
22
+ return [_jsonable(v) for v in value]
23
+ if isinstance(value, np.ndarray):
24
+ return _jsonable(value.tolist())
25
+ if isinstance(value, np.generic):
26
+ return _jsonable(value.item())
27
+ if isinstance(value, float) and not math.isfinite(value):
28
+ return None
29
+ return value
30
+
31
+
32
+ @dataclass
33
+ class IndicatorResult:
34
+ """Outcome of one SDG 11 indicator computation.
35
+
36
+ Attributes:
37
+ indicator: Official indicator code, e.g. ``"11.2.1"``.
38
+ name: Short human-readable indicator name.
39
+ value: Headline value in ``unit`` (``None`` when it is mathematically undefined,
40
+ e.g. a zero population growth rate in 11.3.1).
41
+ unit: Unit of ``value`` (``"%"``, ``"ratio"``, ``"ug/m3"``...).
42
+ components: Intermediate quantities and companion metrics, keyed by name.
43
+ metadata: Indicator code, method version, parameters and citations.
44
+ how_to_report: Short guidance on reporting the value.
45
+ disaggregation: Optional breakdown of ``value`` by population group.
46
+ warnings: Data-quality notes raised during the computation.
47
+ """
48
+
49
+ indicator: str
50
+ name: str
51
+ value: Optional[float]
52
+ unit: str
53
+ components: Dict[str, Any] = field(default_factory=dict)
54
+ metadata: Dict[str, Any] = field(default_factory=dict)
55
+ how_to_report: str = ""
56
+ disaggregation: Dict[str, Any] = field(default_factory=dict)
57
+ warnings: List[str] = field(default_factory=list)
58
+
59
+ @property
60
+ def citations(self) -> List[Citation]:
61
+ """Citations attached to the method."""
62
+ cites = self.metadata.get("citations", [])
63
+ return [c for c in cites if isinstance(c, Citation)]
64
+
65
+ def to_dict(self) -> Dict[str, Any]:
66
+ """Return a JSON-serialisable dictionary."""
67
+ out: Dict[str, Any] = {
68
+ "indicator": self.indicator,
69
+ "name": self.name,
70
+ "value": self.value,
71
+ "unit": self.unit,
72
+ "components": self.components,
73
+ "disaggregation": self.disaggregation,
74
+ "metadata": self.metadata,
75
+ "how_to_report": self.how_to_report,
76
+ "warnings": self.warnings,
77
+ }
78
+ result = _jsonable(out)
79
+ assert isinstance(result, dict)
80
+ return result
81
+
82
+ def to_json(self, indent: Optional[int] = 2) -> str:
83
+ """Serialise to a JSON string."""
84
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
85
+
86
+ def formatted_value(self, digits: int = 2) -> str:
87
+ """Value rounded for tables, or ``"n/a"`` when undefined."""
88
+ if self.value is None or not math.isfinite(self.value):
89
+ return "n/a"
90
+ return f"{self.value:.{digits}f}"
91
+
92
+ def __str__(self) -> str:
93
+ unit = "" if self.unit in ("", "ratio") else f" {self.unit}"
94
+ return f"SDG {self.indicator} {self.name}: {self.formatted_value()}{unit}"
95
+
96
+
97
+ def build_metadata(
98
+ indicator: str,
99
+ method_version: str,
100
+ citations: List[Citation],
101
+ parameters: Optional[Dict[str, Any]] = None,
102
+ ) -> Dict[str, Any]:
103
+ """Assemble the standard metadata block used by every result."""
104
+ from . import __version__
105
+
106
+ return {
107
+ "indicator": indicator,
108
+ "method_version": method_version,
109
+ "software": f"sdg11 {__version__}",
110
+ "parameters": dict(parameters or {}),
111
+ "citations": list(citations),
112
+ "disclaimer": (
113
+ "Computed with the open-source sdg11 package; not an official UN or "
114
+ "UN-Habitat product. Validate against national statistical procedures "
115
+ "before official reporting."
116
+ ),
117
+ }
sdg11/air_quality.py ADDED
@@ -0,0 +1,166 @@
1
+ """SDG 11.6.2 - Annual mean levels of fine particulate matter in cities (population weighted).
2
+
3
+ The custodian agency (WHO) defines the indicator as the annual mean concentration of
4
+ PM2.5 (or PM10) to which the urban population is exposed, weighted by population::
5
+
6
+ C_pw = sum(P_i * C_i) / sum(P_i)
7
+
8
+ over the population units ``i`` (grid cells or neighbourhoods) of the city. Exposure
9
+ is compared with the 2021 WHO air quality guideline levels and interim targets.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any, Dict, List, Optional, Tuple
15
+
16
+ import numpy as np
17
+
18
+ from ._result import IndicatorResult, build_metadata
19
+ from .citations import WHO_11_6_2, WHO_AQG_2021
20
+ from .geometry import as_points, nearest
21
+
22
+ METHOD_VERSION = "WHO 11.6.2 metadata, population-weighted annual mean (sdg11 v1)"
23
+
24
+ #: 2021 WHO annual-mean levels in ug/m3: interim targets 1-4 and the guideline (AQG).
25
+ WHO_2021_ANNUAL: Dict[str, Dict[str, float]] = {
26
+ "PM2.5": {"IT-1": 35.0, "IT-2": 25.0, "IT-3": 15.0, "IT-4": 10.0, "AQG": 5.0},
27
+ "PM10": {"IT-1": 70.0, "IT-2": 50.0, "IT-3": 30.0, "IT-4": 20.0, "AQG": 15.0},
28
+ }
29
+
30
+ HOW_TO_REPORT = (
31
+ "Report the population-weighted annual mean concentration in ug/m3 for the city "
32
+ "(urban centre), the pollutant (PM2.5 preferred), reference year, concentration source "
33
+ "(monitors, satellite-derived model or fusion) and population source. Add the share of "
34
+ "the population exposed above the WHO 2021 guideline and interim targets."
35
+ )
36
+
37
+
38
+ def _pollutant_key(pollutant: str) -> str:
39
+ key = pollutant.strip().upper().replace("_", ".").replace(" ", "")
40
+ if key in ("PM2.5", "PM25"):
41
+ return "PM2.5"
42
+ if key == "PM10":
43
+ return "PM10"
44
+ raise ValueError("pollutant must be 'PM2.5' or 'PM10'.")
45
+
46
+
47
+ def annual_mean(
48
+ values: Any, expected_count: Optional[int] = None, min_coverage: float = 0.75
49
+ ) -> Tuple[Optional[float], float]:
50
+ """Annual mean of a monitor time series with a data-completeness check.
51
+
52
+ Args:
53
+ values: Daily (or hourly) concentrations; ``NaN`` marks missing records.
54
+ expected_count: Records expected in the year (e.g. 365 or 8760). Defaults to the
55
+ length of ``values``.
56
+ min_coverage: Minimum share of valid records (75 % is the usual criterion).
57
+
58
+ Returns:
59
+ ``(mean, coverage)``; ``mean`` is ``None`` when coverage is insufficient.
60
+ """
61
+ arr = np.asarray(values, dtype=float).ravel()
62
+ n_expected = int(expected_count) if expected_count is not None else arr.size
63
+ if n_expected <= 0:
64
+ raise ValueError("expected_count must be positive.")
65
+ valid = arr[np.isfinite(arr)]
66
+ coverage = valid.size / n_expected
67
+ if coverage < min_coverage or valid.size == 0:
68
+ return None, coverage
69
+ return float(valid.mean()), coverage
70
+
71
+
72
+ def assign_nearest_station(
73
+ population_xy: Any,
74
+ station_xy: Any,
75
+ station_values: Any,
76
+ max_distance_m: float = float("inf"),
77
+ ) -> np.ndarray:
78
+ """Give each population point the value of its nearest monitoring station.
79
+
80
+ Points farther than ``max_distance_m`` from any station get ``NaN`` (and are then
81
+ excluded by :func:`population_weighted_concentration`).
82
+ """
83
+ vals = np.asarray(station_values, dtype=float).ravel()
84
+ if vals.shape[0] != as_points(station_xy).shape[0]:
85
+ raise ValueError("station_values must have one value per station.")
86
+ dist, idx = nearest(population_xy, station_xy)
87
+ out = np.full(dist.shape[0], np.nan)
88
+ ok = (idx >= 0) & (dist <= max_distance_m)
89
+ out[ok] = vals[idx[ok]]
90
+ return out
91
+
92
+
93
+ def population_weighted_concentration(
94
+ concentration: Any,
95
+ population: Any,
96
+ pollutant: str = "PM2.5",
97
+ year: Optional[int] = None,
98
+ ) -> IndicatorResult:
99
+ """Population-weighted annual mean PM concentration (SDG 11.6.2).
100
+
101
+ Args:
102
+ concentration: Annual mean concentration (ug/m3) per population unit; any shape,
103
+ ``NaN`` for missing values.
104
+ population: Population per unit, same shape as ``concentration``.
105
+ pollutant: ``"PM2.5"`` or ``"PM10"``.
106
+ year: Optional reference year stored in the metadata.
107
+ """
108
+ key = _pollutant_key(pollutant)
109
+ c = np.asarray(concentration, dtype=float).ravel()
110
+ p = np.asarray(population, dtype=float).ravel()
111
+ if c.shape != p.shape:
112
+ raise ValueError("concentration and population must have the same shape.")
113
+ p = np.where(np.isfinite(p) & (p > 0), p, 0.0)
114
+ ok = np.isfinite(c) & (c >= 0)
115
+ warnings: List[str] = []
116
+ excluded = float(p[~ok].sum())
117
+ total = float(p.sum())
118
+ if excluded > 0:
119
+ warnings.append(
120
+ f"{excluded:.0f} residents ({100.0 * excluded / total:.1f} %) live in units without "
121
+ "a valid concentration and were excluded."
122
+ )
123
+ covered = float(p[ok].sum())
124
+ value: Optional[float] = None
125
+ if covered > 0:
126
+ value = float((p[ok] * c[ok]).sum() / covered)
127
+ else:
128
+ warnings.append("No population with a valid concentration; the indicator is undefined.")
129
+ exceed: Dict[str, Any] = {}
130
+ for level, threshold in WHO_2021_ANNUAL[key].items():
131
+ above = ok & (c > threshold)
132
+ exceed[level] = {
133
+ "threshold_ug_m3": threshold,
134
+ "population_above": float(p[above].sum()),
135
+ "share_above_pct": 100.0 * float(p[above].sum()) / covered if covered else None,
136
+ }
137
+ components: Dict[str, Any] = {
138
+ "pollutant": key,
139
+ "population_weighted_mean_ug_m3": value,
140
+ "unweighted_mean_ug_m3": float(c[ok].mean()) if ok.any() else None,
141
+ "min_ug_m3": float(c[ok].min()) if ok.any() else None,
142
+ "max_ug_m3": float(c[ok].max()) if ok.any() else None,
143
+ "total_population": total,
144
+ "population_with_data": covered,
145
+ "n_units": int(c.size),
146
+ "n_units_with_data": int(ok.sum()),
147
+ "who_2021_exceedance": exceed,
148
+ "ratio_to_who_guideline": (
149
+ value / WHO_2021_ANNUAL[key]["AQG"] if value is not None else None
150
+ ),
151
+ }
152
+ return IndicatorResult(
153
+ indicator="11.6.2",
154
+ name=f"Population-weighted annual mean {key}",
155
+ value=value,
156
+ unit="ug/m3",
157
+ components=components,
158
+ metadata=build_metadata(
159
+ "11.6.2",
160
+ METHOD_VERSION,
161
+ [WHO_11_6_2, WHO_AQG_2021],
162
+ {"pollutant": key, "year": year},
163
+ ),
164
+ how_to_report=HOW_TO_REPORT,
165
+ warnings=warnings,
166
+ )
sdg11/citations.py ADDED
@@ -0,0 +1,160 @@
1
+ """Bibliographic references for the methods implemented in :mod:`sdg11`.
2
+
3
+ The indicator metadata documents are maintained by the custodian agencies on the
4
+ UN Statistics Division SDG metadata repository. They are revised from time to
5
+ time; always check the current version before official reporting.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass
11
+ from typing import Dict, Optional
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Citation:
16
+ """A single reference."""
17
+
18
+ key: str
19
+ authors: str
20
+ year: str
21
+ title: str
22
+ publisher: str
23
+ url: Optional[str] = None
24
+
25
+ def to_dict(self) -> Dict[str, Optional[str]]:
26
+ return asdict(self)
27
+
28
+ def __str__(self) -> str:
29
+ text = f"{self.authors} ({self.year}). {self.title}. {self.publisher}."
30
+ if self.url:
31
+ text += f" {self.url}"
32
+ return text
33
+
34
+
35
+ _METADATA = "https://unstats.un.org/sdgs/metadata/files/"
36
+
37
+ UN_HABITAT_11_1_1 = Citation(
38
+ key="unhabitat-11.1.1",
39
+ authors="UN-Habitat",
40
+ year="current",
41
+ title=(
42
+ "SDG indicator metadata 11.1.1: Proportion of urban population living in slums, "
43
+ "informal settlements or inadequate housing"
44
+ ),
45
+ publisher="United Nations Statistics Division, SDG metadata repository",
46
+ url=_METADATA + "Metadata-11-01-01.pdf",
47
+ )
48
+
49
+ UN_HABITAT_11_2_1 = Citation(
50
+ key="unhabitat-11.2.1",
51
+ authors="UN-Habitat",
52
+ year="current",
53
+ title=(
54
+ "SDG indicator metadata 11.2.1: Proportion of population that has convenient "
55
+ "access to public transport, by sex, age and persons with disabilities"
56
+ ),
57
+ publisher="United Nations Statistics Division, SDG metadata repository",
58
+ url=_METADATA + "Metadata-11-02-01.pdf",
59
+ )
60
+
61
+ UN_HABITAT_11_3_1 = Citation(
62
+ key="unhabitat-11.3.1",
63
+ authors="UN-Habitat",
64
+ year="current",
65
+ title=(
66
+ "SDG indicator metadata 11.3.1: Ratio of land consumption rate to population growth rate"
67
+ ),
68
+ publisher="United Nations Statistics Division, SDG metadata repository",
69
+ url=_METADATA + "Metadata-11-03-01.pdf",
70
+ )
71
+
72
+ WHO_11_6_2 = Citation(
73
+ key="who-11.6.2",
74
+ authors="World Health Organization",
75
+ year="current",
76
+ title=(
77
+ "SDG indicator metadata 11.6.2: Annual mean levels of fine particulate matter "
78
+ "(e.g. PM2.5 and PM10) in cities (population weighted)"
79
+ ),
80
+ publisher="United Nations Statistics Division, SDG metadata repository",
81
+ url=_METADATA + "Metadata-11-06-02.pdf",
82
+ )
83
+
84
+ WHO_AQG_2021 = Citation(
85
+ key="who-aqg-2021",
86
+ authors="World Health Organization",
87
+ year="2021",
88
+ title=(
89
+ "WHO global air quality guidelines: particulate matter (PM2.5 and PM10), ozone, "
90
+ "nitrogen dioxide, sulfur dioxide and carbon monoxide"
91
+ ),
92
+ publisher="World Health Organization, Geneva",
93
+ url="https://www.who.int/publications/i/item/9789240034228",
94
+ )
95
+
96
+ UN_HABITAT_11_7_1 = Citation(
97
+ key="unhabitat-11.7.1",
98
+ authors="UN-Habitat",
99
+ year="current",
100
+ title=(
101
+ "SDG indicator metadata 11.7.1: Average share of the built-up area of cities that "
102
+ "is open space for public use for all, by sex, age and persons with disabilities"
103
+ ),
104
+ publisher="United Nations Statistics Division, SDG metadata repository",
105
+ url=_METADATA + "Metadata-11-07-01.pdf",
106
+ )
107
+
108
+ DEGURBA_MANUAL_2021 = Citation(
109
+ key="degurba-manual-2021",
110
+ authors="European Commission, FAO, ILO, OECD, UN-Habitat and World Bank",
111
+ year="2021",
112
+ title=(
113
+ "Applying the Degree of Urbanisation: A methodological manual to define cities, "
114
+ "towns and rural areas for international comparisons (2021 edition)"
115
+ ),
116
+ publisher="Publications Office of the European Union, Luxembourg",
117
+ url="https://ec.europa.eu/eurostat/web/products-manuals-and-guidelines/-/ks-02-20-499",
118
+ )
119
+
120
+ DIJKSTRA_2021 = Citation(
121
+ key="dijkstra-2021",
122
+ authors="Dijkstra, L., Florczyk, A. J., Freire, S., Kemper, T., Melchiorri, M., "
123
+ "Pesaresi, M. and Schiavina, M.",
124
+ year="2021",
125
+ title=(
126
+ "Applying the Degree of Urbanisation to the globe: A new harmonised definition "
127
+ "reveals a different picture of global urbanisation"
128
+ ),
129
+ publisher="Journal of Urban Economics 125, 103312",
130
+ url="https://doi.org/10.1016/j.jue.2020.103312",
131
+ )
132
+
133
+ UNSC_2020_DEGURBA = Citation(
134
+ key="unsc-51-2020",
135
+ authors="United Nations Statistical Commission",
136
+ year="2020",
137
+ title=(
138
+ "Report on the fifty-first session: endorsement of the Degree of Urbanisation as the "
139
+ "recommended "
140
+ "method for delineating cities, urban and rural areas for international "
141
+ "statistical comparisons"
142
+ ),
143
+ publisher="UN Statistical Commission, 51st session",
144
+ url="https://unstats.un.org/unsd/statcom/51st-session/",
145
+ )
146
+
147
+ ALL_CITATIONS = {
148
+ c.key: c
149
+ for c in (
150
+ UN_HABITAT_11_1_1,
151
+ UN_HABITAT_11_2_1,
152
+ UN_HABITAT_11_3_1,
153
+ WHO_11_6_2,
154
+ WHO_AQG_2021,
155
+ UN_HABITAT_11_7_1,
156
+ DEGURBA_MANUAL_2021,
157
+ DIJKSTRA_2021,
158
+ UNSC_2020_DEGURBA,
159
+ )
160
+ }
sdg11/cli.py ADDED
@@ -0,0 +1,127 @@
1
+ """Command-line interface: ``sdg11 degurba``, ``report``, ``demo`` and ``citations``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import List, Optional
10
+
11
+ from . import __version__, io
12
+ from .citations import ALL_CITATIONS
13
+ from .config import run_config
14
+ from .degurba import classify_degurba
15
+
16
+
17
+ def _cmd_degurba(args: argparse.Namespace) -> int:
18
+ grid, area, info = io.read_grid(args.grid)
19
+ cell = args.cell_area_km2 if args.cell_area_km2 is not None else area
20
+ if cell is None:
21
+ print(
22
+ "Cell area unknown for this format; assuming 1 km2 (use --cell-area-km2).",
23
+ file=sys.stderr,
24
+ )
25
+ cell = 1.0
26
+ result = classify_degurba(
27
+ grid,
28
+ cell_area_km2=cell,
29
+ fill_gaps=not args.no_gap_fill,
30
+ backend=args.backend,
31
+ )
32
+ print(f"Degree of Urbanisation (level 1) for {args.grid}")
33
+ print(f"Grid {grid.shape[0]} x {grid.shape[1]} cells, cell area {cell:g} km2")
34
+ print(
35
+ f"Urban centres: {result.components['n_urban_centres']}, "
36
+ f"urban clusters: {result.components['n_urban_clusters']}"
37
+ )
38
+ print()
39
+ print(result.summary_table())
40
+ for w in result.warnings:
41
+ print(f"warning: {w}", file=sys.stderr)
42
+ if args.out:
43
+ io.write_grid(args.out, result.classes, info)
44
+ print(
45
+ f"\nClass grid written to {args.out} (3 urban centre, 2 urban cluster, 1 rural, 0 no data)"
46
+ )
47
+ if args.json:
48
+ Path(args.json).write_text(json.dumps(result.to_dict(), indent=2), encoding="utf-8")
49
+ print(f"Summary written to {args.json}")
50
+ return 0
51
+
52
+
53
+ def _cmd_report(args: argparse.Namespace) -> int:
54
+ rep = run_config(args.config)
55
+ rep.save(json_path=args.json, markdown_path=args.markdown)
56
+ if args.json:
57
+ print(f"JSON report written to {args.json}", file=sys.stderr)
58
+ if args.markdown:
59
+ print(f"Markdown report written to {args.markdown}", file=sys.stderr)
60
+ if not args.quiet:
61
+ print(rep.to_markdown())
62
+ return 0
63
+
64
+
65
+ def _cmd_demo(args: argparse.Namespace) -> int:
66
+ from .synthetic import write_synthetic_city
67
+
68
+ path = write_synthetic_city(args.directory)
69
+ print(f"Synthetic example data written to {args.directory}")
70
+ print(f"Run: sdg11 report {path}")
71
+ return 0
72
+
73
+
74
+ def _cmd_citations(args: argparse.Namespace) -> int:
75
+ for c in ALL_CITATIONS.values():
76
+ print(f"- {c}")
77
+ return 0
78
+
79
+
80
+ def build_parser() -> argparse.ArgumentParser:
81
+ parser = argparse.ArgumentParser(
82
+ prog="sdg11",
83
+ description="Compute UN SDG 11 urban indicators and the Degree of Urbanisation.",
84
+ )
85
+ parser.add_argument("--version", action="version", version=f"sdg11 {__version__}")
86
+ sub = parser.add_subparsers(dest="command")
87
+
88
+ p_deg = sub.add_parser("degurba", help="Classify a population grid (DEGURBA level 1).")
89
+ p_deg.add_argument("grid", help="Population grid: .tif (needs rasterio), .asc, .npy or .csv")
90
+ p_deg.add_argument("--cell-area-km2", type=float, default=None, help="Override the cell area.")
91
+ p_deg.add_argument("--out", help="Write the class grid (.tif, .asc, .npy or .csv).")
92
+ p_deg.add_argument("--json", help="Write the summary as JSON.")
93
+ p_deg.add_argument("--no-gap-fill", action="store_true", help="Skip urban-centre gap filling.")
94
+ p_deg.add_argument("--backend", choices=["auto", "scipy", "numpy"], default="auto")
95
+ p_deg.set_defaults(func=_cmd_degurba)
96
+
97
+ p_rep = sub.add_parser("report", help="Run every indicator in a TOML configuration.")
98
+ p_rep.add_argument("config", help="Path to the TOML configuration file.")
99
+ p_rep.add_argument("--json", help="Write the JSON report here.")
100
+ p_rep.add_argument("--markdown", help="Write the Markdown report here.")
101
+ p_rep.add_argument("--quiet", action="store_true", help="Do not print the Markdown report.")
102
+ p_rep.set_defaults(func=_cmd_report)
103
+
104
+ p_demo = sub.add_parser("demo", help="Write a synthetic example dataset and config.")
105
+ p_demo.add_argument("directory", help="Output folder (created if missing).")
106
+ p_demo.set_defaults(func=_cmd_demo)
107
+
108
+ p_cit = sub.add_parser("citations", help="List the methodological references.")
109
+ p_cit.set_defaults(func=_cmd_citations)
110
+ return parser
111
+
112
+
113
+ def main(argv: Optional[List[str]] = None) -> int:
114
+ parser = build_parser()
115
+ args = parser.parse_args(argv)
116
+ if not getattr(args, "func", None):
117
+ parser.print_help()
118
+ return 1
119
+ try:
120
+ return int(args.func(args))
121
+ except (OSError, ValueError, KeyError, ImportError) as exc:
122
+ print(f"sdg11: error: {exc}", file=sys.stderr)
123
+ return 2
124
+
125
+
126
+ if __name__ == "__main__": # pragma: no cover
127
+ sys.exit(main())