bayesian-pv-census 0.1.0__tar.gz

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,12 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ dist/
4
+ build/
5
+ .pytest_cache/
6
+ .venv/
7
+ venv/
8
+
9
+ # Audited reference values from the French transmission system operator.
10
+ # Redistribution is pending their clearance; see issue #1. Remove this entry,
11
+ # drop the file back in, and bump the minor version once it is granted.
12
+ src/bayesian_pv_census/data/france_2024_reported.csv
@@ -0,0 +1,28 @@
1
+ cff-version: 1.2.0
2
+ title: "bayesian-pv-census: turn a detector's output into a census, then audit a register against it"
3
+ message: "If you use this software, please cite both the software and the paper it implements."
4
+ type: software
5
+ authors:
6
+ - family-names: Kasmi
7
+ given-names: Gabriel
8
+ email: gabkasmi@gmail.com
9
+ orcid: "https://orcid.org/0000-0002-7774-4302"
10
+ affiliation: "Mines Paris-PSL"
11
+ version: 0.1.0
12
+ license: MIT
13
+ repository-code: "https://github.com/gabrielkasmi/bayesian-pv-census"
14
+ abstract: >-
15
+ Reference implementation of the statistical core of a registry-audit protocol:
16
+ Beta posteriors on detector precision and recall, propagation to a corrected
17
+ quantity, a credible-interval decision rule against a reported value, a
18
+ specification battery and its hard core, a closed-form annotation budget, and
19
+ the cost of forming the correction factor at a coarser spatial scale. Ships
20
+ the paper's French dataset, 93 reporting units, as a demo.
21
+ references:
22
+ - type: article
23
+ title: "Auditing photovoltaic registries with remote sensing"
24
+ authors:
25
+ - family-names: Kasmi
26
+ given-names: Gabriel
27
+ journal: Joule
28
+ year: 2026
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gabriel Kasmi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,233 @@
1
+ Metadata-Version: 2.5
2
+ Name: bayesian-pv-census
3
+ Version: 0.1.0
4
+ Summary: Turn a detector's output into a census with credible intervals, then audit a register against it
5
+ Project-URL: Homepage, https://github.com/gabrielkasmi/bayesian-pv-census
6
+ Project-URL: Paper, https://doi.org/10.5281/zenodo.21534856
7
+ Author-email: Gabriel Kasmi <gabkasmi@gmail.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: audit,bayesian,census,measurement error,photovoltaic,registry,remote sensing
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering
16
+ Requires-Python: >=3.10
17
+ Requires-Dist: numpy>=1.23
18
+ Requires-Dist: pandas>=1.5
19
+ Requires-Dist: scipy>=1.9
20
+ Provides-Extra: dev
21
+ Requires-Dist: matplotlib>=3.6; extra == 'dev'
22
+ Requires-Dist: pytest>=7; extra == 'dev'
23
+ Provides-Extra: figures
24
+ Requires-Dist: matplotlib>=3.6; extra == 'figures'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # bayesian-pv-census
28
+
29
+ **Turn a detector's raw count into a census with credible intervals, then audit a register against it.**
30
+
31
+ A detector that finds objects in imagery misses some and invents others, at rates
32
+ that vary from place to place. Its raw total is therefore not a measurement, and
33
+ comparing it directly to an official register says as much about the detector as
34
+ about the register. This package closes that gap: given a validation sample, it
35
+ turns the raw total into a posterior over the true quantity, and turns that
36
+ posterior into a verdict on whatever the register reports.
37
+
38
+ Nothing here knows about photovoltaics, France, or geometry. A *unit* is anything
39
+ with a raw total and a validation sample — a department, a grid cell, a utility
40
+ service area. `raw` is whatever you chose to count: installed capacity, number of
41
+ installations, roof area.
42
+
43
+ This is the statistical core of *Nationally Consistent, Locally Incomplete: A Bayesian Remote-Sensing Audit of Rooftop Photovoltaic Registries* (Kasmi et al., 2026, pending peer review) , extracted as a
44
+ library. Every public function corresponds to a named component of the paper.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install bayesian-pv-census # numpy, scipy, pandas
50
+ pip install 'bayesian-pv-census[figures]' # adds matplotlib
51
+ ```
52
+
53
+ ## One minute
54
+
55
+ ```python
56
+ from bayesian_pv_census import UnitRecord, correct_unit, evaluate
57
+
58
+ unit = UnitRecord(
59
+ unit_id="dept_86",
60
+ raw=26_800, # what the detector found, any consistent unit
61
+ precision_tp=116, precision_fp=4, # 120 detections checked by hand
62
+ recall_tp=68, recall_fn=33, # 101 real objects located independently
63
+ reported_value=26_820, # what the register reports
64
+ )
65
+
66
+ result = correct_unit(unit)
67
+ print(f"{result.mean:,.0f} 99% CI {result.ci(0.99)}")
68
+ print(evaluate(result, unit.reported_value).status) # below | within | above
69
+ ```
70
+
71
+ The verdict is decided by the interval, not the gap. A large discrepancy inside a
72
+ wide interval is not a finding; a small one outside a tight interval is.
73
+
74
+ ## The paper's data
75
+
76
+ ```python
77
+ from bayesian_pv_census import correct_batch, load_demo
78
+
79
+ units = load_demo() # 93 French reporting units, kWp
80
+ correct_batch(units, prior="empirical_bayes")["corrected_mean"].sum()
81
+ # 4_033_003 kWp, the paper's national estimate
82
+ ```
83
+
84
+ `load_demo()` returns the 93 continental French reporting units behind the
85
+ published audit: detected rooftop capacity below 36 kWp, the manual validation
86
+ counts (31,853 annotations), and the size-weighted rates of specification B.
87
+
88
+ **One column is not yet included.** The reference values under audit are the
89
+ French transmission system operator's grid-connection registry, and
90
+ redistributing them is not ours to grant. Until that clearance arrives the demo
91
+ supports estimation but not the audit, `load_demo()` says so, and
92
+ `has_reported_values()` reports it:
93
+
94
+ ```python
95
+ from bayesian_pv_census import has_reported_values
96
+ has_reported_values() # False in this release
97
+ ```
98
+
99
+ Nothing is substituted in the meantime. A column of plausible-looking
100
+ placeholders would be indistinguishable from data at a glance, and reproducing a
101
+ published audit against invented references is worse than not reproducing it. The
102
+ four regression tests that need the column are skipped rather than weakened, so
103
+ the skip count in `pytest` is the honest signal. The national estimate above
104
+ needs no reference value and is checked in every release.
105
+
106
+ Once the column ships, the full battery reproduces the paper: 25 units
107
+ under-reported and 8 over-reported under specification A, an 18-unit hard core
108
+ and a 7-unit negative control.
109
+
110
+ Installation counts are deliberately absent for a different reason. The audit in
111
+ the paper is about capacity; shipping a count column would imply a quantity that
112
+ was never audited.
113
+
114
+ ## What the correction assumes
115
+
116
+ The estimator is `raw × P/R`. Four assumptions stand behind it, and the package
117
+ can only speak to two of them.
118
+
119
+ | | Assumption | Can the package check it? |
120
+ |---|---|---|
121
+ | **H1** | Detection status is independent of object size: true positives, false positives and false negatives have the same mean size. | Partly. The gap between specifications A and B measures the violation, and B does not require H1. |
122
+ | **H2** | The detector is run over the entire unit; no sub-region is excluded. | No. Upstream of anything the package sees. |
123
+ | **H3** | The validation samples are drawn representatively from, respectively, the raw detections and the true population. | No. This is a property of how you annotated, and nothing in the counts reveals it. |
124
+ | **H4** | For a correctly detected object, its estimated size is unbiased for its true size. | Only its sensitivity, via the `scale` key of `run_battery`. |
125
+
126
+ H4 binds only when the quantity is a size. **If you correct a count of objects
127
+ rather than a capacity, H4 drops out entirely** — which is why the field is named
128
+ `raw` and not `raw_capacity`.
129
+
130
+ Two of the four are therefore assumptions you carry, not results the package
131
+ delivers. Reporting a credible interval without saying which of H2 and H3 you
132
+ believe, and why, states less than it appears to.
133
+
134
+ ## Before you annotate
135
+
136
+ The interval's half-width has a closed form, so the annotation effort can be
137
+ budgeted in advance rather than discovered afterwards:
138
+
139
+ ```python
140
+ from bayesian_pv_census import required_sample_size
141
+
142
+ b = required_sample_size(target_half_width=0.15, expected_precision=0.85,
143
+ expected_recall=0.65, level=0.99)
144
+ print(b.n_precision, b.n_recall) # annotations needed per unit
145
+ ```
146
+
147
+ ## Specifications and the hard core
148
+
149
+ A verdict that only holds under one way of computing precision and recall is not
150
+ a finding. `run_battery` runs the audit under several and keeps what survives all
151
+ of them.
152
+
153
+ ```python
154
+ from bayesian_pv_census import run_battery
155
+
156
+ battery = run_battery(units, prior="empirical_bayes")
157
+ battery.hard_core("below") # flagged under-reported by every specification
158
+ battery.negative_control() # flagged the other way, unanimously — the control group
159
+ battery.concordance_table() # crosstab; empty off-diagonal corners mean no sign flips
160
+ ```
161
+
162
+ Specification A counts annotated objects. Specification B weights them by size.
163
+ **B is implemented exactly as in the paper**, which computes the weighted rates as
164
+ point estimates and applies them as a deterministic rescaling of A's posterior —
165
+ so B's interval is A's interval, shifted. That is a known limitation of the
166
+ published method; the package reproduces it rather than improving on it, because
167
+ reproducing the paper is the point. See the docstring of `correct_unit` for what
168
+ a properly weighted posterior would require, and for why the `min_weighted_n`
169
+ fallback matters more than its name suggests.
170
+
171
+ A third axis needs no specification of its own. Rescaling every raw quantity by a
172
+ constant — a different surface-to-power coefficient, a different filtering
173
+ threshold — multiplies the posterior and both its bounds while the reported value
174
+ stays put, so it is passed generically:
175
+
176
+ ```python
177
+ run_battery(units, specs={"A": {}, "B": {"weighted": True},
178
+ "C_low": {"scale": 5.5 / 5.0}})
179
+ ```
180
+
181
+ The status is monotone in that constant and flips once, so the flipping point has
182
+ a closed form and no sweep is needed:
183
+
184
+ ```python
185
+ from bayesian_pv_census import conversion_threshold
186
+ conversion_threshold(result, unit.reported_value, "below")
187
+ ```
188
+
189
+ The coefficient itself stays outside the engine. `raw` arrives already converted,
190
+ and a `conversion_coefficient` argument would import photovoltaics into a core
191
+ that knows nothing about it.
192
+
193
+ ## What forming the factor at a coarser scale costs
194
+
195
+ Correcting each unit and summing is not the same as pooling the units' rates and
196
+ correcting once. `1/R` is convex, so pooling always yields a smaller factor and a
197
+ smaller total. The cost is exactly zero when recall is homogeneous across the
198
+ pooled units, whatever the dispersion of precision, and second order in the
199
+ coefficient of variation of recall otherwise:
200
+
201
+ ```python
202
+ from bayesian_pv_census import compare_aggregation_scales, pooling_penalty
203
+
204
+ compare_aggregation_scales(units, groups={"north": [...], "south": [...]}).totals
205
+ pooling_penalty(units) # CV(R)^2 - rho * CV(P) * CV(R), and its two terms
206
+ ```
207
+
208
+ The practical consequence is that there is no optimal grid to search for. Forming
209
+ the factor at the finest level your annotation budget supports is weakly better
210
+ in every case.
211
+
212
+ ## Scope
213
+
214
+ Out of scope by design, and unlikely to change: geospatial sampling, annotation
215
+ tooling, temporal alignment between imagery and register, and detector-specific
216
+ parsers. Those are format- and project-specific; this package starts once you can
217
+ write down a raw total and a validation sample.
218
+
219
+ ## Tests
220
+
221
+ ```bash
222
+ pip install -e '.[dev]' && pytest
223
+ ```
224
+
225
+ The suite validates the mathematics on synthetic data — the closed form against
226
+ the bootstrap, the monotonicity of the budgeting rule, the structural invariants
227
+ of the hard core, the exact vanishing of the pooling penalty under homogeneous
228
+ recall — and then checks the published French numbers against the shipped demo
229
+ data, so a regression in the engine cannot pass silently.
230
+
231
+ ## Citation
232
+
233
+ See `CITATION.cff`. Licence MIT.
@@ -0,0 +1,207 @@
1
+ # bayesian-pv-census
2
+
3
+ **Turn a detector's raw count into a census with credible intervals, then audit a register against it.**
4
+
5
+ A detector that finds objects in imagery misses some and invents others, at rates
6
+ that vary from place to place. Its raw total is therefore not a measurement, and
7
+ comparing it directly to an official register says as much about the detector as
8
+ about the register. This package closes that gap: given a validation sample, it
9
+ turns the raw total into a posterior over the true quantity, and turns that
10
+ posterior into a verdict on whatever the register reports.
11
+
12
+ Nothing here knows about photovoltaics, France, or geometry. A *unit* is anything
13
+ with a raw total and a validation sample — a department, a grid cell, a utility
14
+ service area. `raw` is whatever you chose to count: installed capacity, number of
15
+ installations, roof area.
16
+
17
+ This is the statistical core of *Nationally Consistent, Locally Incomplete: A Bayesian Remote-Sensing Audit of Rooftop Photovoltaic Registries* (Kasmi et al., 2026, pending peer review) , extracted as a
18
+ library. Every public function corresponds to a named component of the paper.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install bayesian-pv-census # numpy, scipy, pandas
24
+ pip install 'bayesian-pv-census[figures]' # adds matplotlib
25
+ ```
26
+
27
+ ## One minute
28
+
29
+ ```python
30
+ from bayesian_pv_census import UnitRecord, correct_unit, evaluate
31
+
32
+ unit = UnitRecord(
33
+ unit_id="dept_86",
34
+ raw=26_800, # what the detector found, any consistent unit
35
+ precision_tp=116, precision_fp=4, # 120 detections checked by hand
36
+ recall_tp=68, recall_fn=33, # 101 real objects located independently
37
+ reported_value=26_820, # what the register reports
38
+ )
39
+
40
+ result = correct_unit(unit)
41
+ print(f"{result.mean:,.0f} 99% CI {result.ci(0.99)}")
42
+ print(evaluate(result, unit.reported_value).status) # below | within | above
43
+ ```
44
+
45
+ The verdict is decided by the interval, not the gap. A large discrepancy inside a
46
+ wide interval is not a finding; a small one outside a tight interval is.
47
+
48
+ ## The paper's data
49
+
50
+ ```python
51
+ from bayesian_pv_census import correct_batch, load_demo
52
+
53
+ units = load_demo() # 93 French reporting units, kWp
54
+ correct_batch(units, prior="empirical_bayes")["corrected_mean"].sum()
55
+ # 4_033_003 kWp, the paper's national estimate
56
+ ```
57
+
58
+ `load_demo()` returns the 93 continental French reporting units behind the
59
+ published audit: detected rooftop capacity below 36 kWp, the manual validation
60
+ counts (31,853 annotations), and the size-weighted rates of specification B.
61
+
62
+ **One column is not yet included.** The reference values under audit are the
63
+ French transmission system operator's grid-connection registry, and
64
+ redistributing them is not ours to grant. Until that clearance arrives the demo
65
+ supports estimation but not the audit, `load_demo()` says so, and
66
+ `has_reported_values()` reports it:
67
+
68
+ ```python
69
+ from bayesian_pv_census import has_reported_values
70
+ has_reported_values() # False in this release
71
+ ```
72
+
73
+ Nothing is substituted in the meantime. A column of plausible-looking
74
+ placeholders would be indistinguishable from data at a glance, and reproducing a
75
+ published audit against invented references is worse than not reproducing it. The
76
+ four regression tests that need the column are skipped rather than weakened, so
77
+ the skip count in `pytest` is the honest signal. The national estimate above
78
+ needs no reference value and is checked in every release.
79
+
80
+ Once the column ships, the full battery reproduces the paper: 25 units
81
+ under-reported and 8 over-reported under specification A, an 18-unit hard core
82
+ and a 7-unit negative control.
83
+
84
+ Installation counts are deliberately absent for a different reason. The audit in
85
+ the paper is about capacity; shipping a count column would imply a quantity that
86
+ was never audited.
87
+
88
+ ## What the correction assumes
89
+
90
+ The estimator is `raw × P/R`. Four assumptions stand behind it, and the package
91
+ can only speak to two of them.
92
+
93
+ | | Assumption | Can the package check it? |
94
+ |---|---|---|
95
+ | **H1** | Detection status is independent of object size: true positives, false positives and false negatives have the same mean size. | Partly. The gap between specifications A and B measures the violation, and B does not require H1. |
96
+ | **H2** | The detector is run over the entire unit; no sub-region is excluded. | No. Upstream of anything the package sees. |
97
+ | **H3** | The validation samples are drawn representatively from, respectively, the raw detections and the true population. | No. This is a property of how you annotated, and nothing in the counts reveals it. |
98
+ | **H4** | For a correctly detected object, its estimated size is unbiased for its true size. | Only its sensitivity, via the `scale` key of `run_battery`. |
99
+
100
+ H4 binds only when the quantity is a size. **If you correct a count of objects
101
+ rather than a capacity, H4 drops out entirely** — which is why the field is named
102
+ `raw` and not `raw_capacity`.
103
+
104
+ Two of the four are therefore assumptions you carry, not results the package
105
+ delivers. Reporting a credible interval without saying which of H2 and H3 you
106
+ believe, and why, states less than it appears to.
107
+
108
+ ## Before you annotate
109
+
110
+ The interval's half-width has a closed form, so the annotation effort can be
111
+ budgeted in advance rather than discovered afterwards:
112
+
113
+ ```python
114
+ from bayesian_pv_census import required_sample_size
115
+
116
+ b = required_sample_size(target_half_width=0.15, expected_precision=0.85,
117
+ expected_recall=0.65, level=0.99)
118
+ print(b.n_precision, b.n_recall) # annotations needed per unit
119
+ ```
120
+
121
+ ## Specifications and the hard core
122
+
123
+ A verdict that only holds under one way of computing precision and recall is not
124
+ a finding. `run_battery` runs the audit under several and keeps what survives all
125
+ of them.
126
+
127
+ ```python
128
+ from bayesian_pv_census import run_battery
129
+
130
+ battery = run_battery(units, prior="empirical_bayes")
131
+ battery.hard_core("below") # flagged under-reported by every specification
132
+ battery.negative_control() # flagged the other way, unanimously — the control group
133
+ battery.concordance_table() # crosstab; empty off-diagonal corners mean no sign flips
134
+ ```
135
+
136
+ Specification A counts annotated objects. Specification B weights them by size.
137
+ **B is implemented exactly as in the paper**, which computes the weighted rates as
138
+ point estimates and applies them as a deterministic rescaling of A's posterior —
139
+ so B's interval is A's interval, shifted. That is a known limitation of the
140
+ published method; the package reproduces it rather than improving on it, because
141
+ reproducing the paper is the point. See the docstring of `correct_unit` for what
142
+ a properly weighted posterior would require, and for why the `min_weighted_n`
143
+ fallback matters more than its name suggests.
144
+
145
+ A third axis needs no specification of its own. Rescaling every raw quantity by a
146
+ constant — a different surface-to-power coefficient, a different filtering
147
+ threshold — multiplies the posterior and both its bounds while the reported value
148
+ stays put, so it is passed generically:
149
+
150
+ ```python
151
+ run_battery(units, specs={"A": {}, "B": {"weighted": True},
152
+ "C_low": {"scale": 5.5 / 5.0}})
153
+ ```
154
+
155
+ The status is monotone in that constant and flips once, so the flipping point has
156
+ a closed form and no sweep is needed:
157
+
158
+ ```python
159
+ from bayesian_pv_census import conversion_threshold
160
+ conversion_threshold(result, unit.reported_value, "below")
161
+ ```
162
+
163
+ The coefficient itself stays outside the engine. `raw` arrives already converted,
164
+ and a `conversion_coefficient` argument would import photovoltaics into a core
165
+ that knows nothing about it.
166
+
167
+ ## What forming the factor at a coarser scale costs
168
+
169
+ Correcting each unit and summing is not the same as pooling the units' rates and
170
+ correcting once. `1/R` is convex, so pooling always yields a smaller factor and a
171
+ smaller total. The cost is exactly zero when recall is homogeneous across the
172
+ pooled units, whatever the dispersion of precision, and second order in the
173
+ coefficient of variation of recall otherwise:
174
+
175
+ ```python
176
+ from bayesian_pv_census import compare_aggregation_scales, pooling_penalty
177
+
178
+ compare_aggregation_scales(units, groups={"north": [...], "south": [...]}).totals
179
+ pooling_penalty(units) # CV(R)^2 - rho * CV(P) * CV(R), and its two terms
180
+ ```
181
+
182
+ The practical consequence is that there is no optimal grid to search for. Forming
183
+ the factor at the finest level your annotation budget supports is weakly better
184
+ in every case.
185
+
186
+ ## Scope
187
+
188
+ Out of scope by design, and unlikely to change: geospatial sampling, annotation
189
+ tooling, temporal alignment between imagery and register, and detector-specific
190
+ parsers. Those are format- and project-specific; this package starts once you can
191
+ write down a raw total and a validation sample.
192
+
193
+ ## Tests
194
+
195
+ ```bash
196
+ pip install -e '.[dev]' && pytest
197
+ ```
198
+
199
+ The suite validates the mathematics on synthetic data — the closed form against
200
+ the bootstrap, the monotonicity of the budgeting rule, the structural invariants
201
+ of the hard core, the exact vanishing of the pooling penalty under homogeneous
202
+ recall — and then checks the published French numbers against the shipped demo
203
+ data, so a regression in the engine cannot pass silently.
204
+
205
+ ## Citation
206
+
207
+ See `CITATION.cff`. Licence MIT.