Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 10 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
# HCP (Hulpmiddel ControleProtocol)
[![Status checks](https://github.com/kiesraad/HCP/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/kiesraad/HCP/actions/workflows/test.yml?query=branch%3Amain)

Deze repository bevat scripts die als hulpmiddel dienen voor het uitvoeren van Onderdeel A van het [Controleprotocol Centraal Stembureau](https://www.kiesraad.nl/binaries/kiesraad/documenten/publicaties/2025/08/21/controleprotocol-centraal-stembureau/Controleprotocol_Centraal_Stembureau.pdf). De scripts worden door Wonderbit op het platform teluitslagen uitgevoerd, waarbij een geüploadde `.zip` met tellingsbestanden (`.eml`) en proces verbalen in `.odt` formaat de trigger zijn.
Deze repository bevat scripts die als hulpmiddel dienen voor het uitvoeren van Onderdeel A van het [Controleprotocol Centraal Stembureau](https://www.kiesraad.nl/binaries/kiesraad/documenten/publicaties/2025/12/16/gr26-controleprotocol-centraal-stembureau/Kiesraad+GR26+Controleprotocol+CSB.pdf). De scripts worden door Wonderbit op het platform teluitslagen uitgevoerd, waarbij een geüploadde `.zip` met daarin het tellingsbestand (`.eml`) de trigger zijn.

## Hoe voer ik het controleprotocol uit?
De gemakkelijkste manier is met behulp van [`uv`](https://docs.astral.sh/uv/getting-started/installation/). Als `uv` geinstalleerd is, is `hcp` te draaien vanuit de root directory. Hiermee wordt automatisch de `.odt` en het `.eml.xml` bestand uit het zip bestand gehaald dat door OSV-2020 geproduceerd wordt gebruikt om `hcp` over te draaien. Bijvoorbeeld:
De gemakkelijkste manier is met behulp van [`uv`](https://docs.astral.sh/uv/getting-started/installation/). Als `uv` geinstalleerd is, is `hcp` te draaien vanuit de root directory. Hiermee wordt automatisch het `.eml.xml` bestand uit het zip bestand gehaald dat door OSV2020-U of Abacus geproduceerd wordt om `hcp` over te draaien. Bijvoorbeeld:
```
uv run hcp definitieve-documenten_tk2060_gemeente_juinen-20600607-152117.zip
```
De output wordt weggeschreven in de directory van waaruit `hcp` aangeroepen is als `a.csv`, `b.csv` en `c.csv`.

Het is ook mogelijk om `hcp` direct op een uitgepakt EML bestand te draaien. Bijvoorbeeld:

```
uv run hcp Telling_GR2026_Juinen_DSO.eml.xml
```

---
De code is ook direct vanuit Python aan te roepen. De functie `create_csv_files` in `main.py` is het ingangspunt voor de code. Parameters voor het aanroepen van deze functie zijn:

- `path_to_xml`: het pad naar het `.eml.xml` bestand waarover je de controle uit wilt voeren. Dit is dus een EML tellingsbestand (`id=510[a-dqrs]`)
- `dest_a`, `dest_b`, `dest_c`: paden waar respectievelijk controlebestanden `a`, `b` en `c` weggeschreven moeten worden. De precieze inhoud van deze bestanden wordt hieronder beschreven
- `path_to_odt`: optionele parameter, pad naar een proces verbaal in `.odt` formaat. Geldige bestanden zijn `Model_Na31-1.odt` voor een decentrale- en `Model_Na31-2.odt` voor een centrale stemopneming.
- `path_to_neighbourhood_data`: optionele parameter, pad naar wijkdata in `.parquet` of `.csv` formaat. In `data/` staat het meest recente beschikbare bestand. (CBS update deze eens in de zoveel tijd, dus deze zal niet altijd 100% up-to-date zijn).

## Lijst met controles
Expand All @@ -24,20 +29,8 @@ Hieronder een korte beschrijving van de controles die onderdeel zijn van HCP. De
| Naam controle | Beschrijving | Output |
|---------------|--------------|--------|
| `check_zero_votes` | Controleert of het totaal aantal stemmen (getelde + ongeldige + blanco stemmen) gelijk is aan 0 | In `b.csv` een "ja" in de kolom "Stembureau met nul stemmen" bij stembureaus waar dit het geval is |
| `check_inexplicable_difference` | Geeft de waarde terug die ingevuld is onder 'geen verklaring' in de EML | In `a.csv` een waarde in de kolom "Aantal geen verklaring voor verschil" bij stembureaus *mits deze ongelijk is aan 0* |
| `check_explanation_sum_difference` | Geeft het verschil tussen (het totaal aantal stemmen en het aantal toegelaten kiezers) en de som van de ingevulde verklaringen. Dus bij 6 stemmen, 5 toegelaten kiezers en een som van verklaringen van 0 is deze waarde 1 (`(6-5) - 0`) | In `a.csv` het aantal dat uit deze verschilberekening komt in de kolom "Aantal ontbrekende verklaringen voor verschil" *mits deze ongelijk is aan 0*
| `check_vote_difference` | Berekent het verschil tussen het aantal toegelaten kiezers en het aantal uitgebrachte stemmen in de EML en geeft deze waarde terug | In `a.csv` het resultaat van deze berekening in de kolom `Niet onderzocht telverschil` *mits het GSB niet heeft aangevinkt dat dat stembureau is onderzocht vanwege een onverklaard verschil (DSO) of dat de toegelaten kiezers opnieuw zijn vastgesteld (CSO)* |
| `check_too_many_rejected_votes` | Controleert of het *percentage* blanco of ongeldige stemmen ten opzichte van het totaal aantal *uitgebrachte* stemmen groter of gelijk is aan een in te stellen percentage. | In `b.csv` een "ja (`{percentage}`%)" in de bijbehorende kolom bij stembureaus waar dit het geval is
| `check_too_many_differences` | Controleert of het absolute verschil tussen toegelaten kiezers ten het totaal aantal uitgebrachte stemmen groter of gelijk is aan een in te stellen percentage *of* absoluuut aantal. | In `b.csv` een "ja (`{percentage}`%)" of "ja (`{aantal}`)" in de bijbehorende kolom bij stembureaus waar dit het geval is
| `check_parties_with_large_percentage_difference` | Controleert of er partijen zijn die bij een stembureau een percentage stemmen heeft behaald dat ten minste een in te stellen aantal percentagepunten verschilt van het gemiddelde in die *gemeente*. Voor de berekening van het gemiddelde in die gemeente wordt het betreffende stembureau niet meegenomen | In `b.csv` de namen van de partijen waarvoor dit het geval is, gescheiden door een komma bij de stembureaus waar dit het geval is
| `check_potentially_switched_candidates` | Controleert of voorkeursstemmen tussen twee kandidaten op dezelfde lijst mogelijk verwisseld zijn. Dat wil zeggen dat een van de kandidaten veel meer (in te stellen hoeveel) stemmen heeft gekregen dan verwacht terwijl een ander veel minder gekregen heeft dan verwacht. | In `b.csv` de paren kandidaten waarvoor dit het geval is, gescheiden door een `, ` in het formaat: *"Mogelijke verwisseling op lijst `i` (`lijstnaam`). Kandidaat `j` had `v_j` stemmen maar verwachting was `e_j`. Kandidaat `k` had `v_k` stemmen maar verwachting was `e_k`"*

## .odt verwerking
Naast de checks die hierboven beschreven zijn, kan ook het proces-verbaal dat bij een telbestand meegeleverd wordt geparsed worden. Het doel hiervan is om stembureau's te identificeren die al een hertelling uitgevoerd hebben. Als dit het geval is, dan wordt in `a.csv` de waarde "x of ja" toegevoegd aan de kolom "Al herteld". In alle gevallen moet zowel het nummer als de naam van het stembureau genoteerd staan om zeker te weten dat we deze koppelen aan het juiste stembureau in de EML. Is deze koppeling om welke reden dan ook niet mogelijk, dan gaat dit script er van uit dat er **niet** herteld is.

De informatie is afkomstig uit:
#### [Model_Na31-1.odt](https://www.rijksoverheid.nl/onderwerpen/verkiezingen/documenten/publicaties/2022/11/18/model-na-31-1)
Stembureaus die onder **3b.** of **3c.** genoteerd staan.

#### [Model_Na31-2.odt](https://www.rijksoverheid.nl/onderwerpen/verkiezingen/documenten/publicaties/2022/11/18/model-na-31-2)
Stembureaus die onder **7.** genoteerd staan.

| `check_potentially_switched_candidates` | Controleert of voorkeursstemmen tussen twee kandidaten op dezelfde lijst mogelijk verwisseld zijn. Dat wil zeggen dat een van de kandidaten veel meer (in te stellen hoeveel) stemmen heeft gekregen dan verwacht terwijl een ander veel minder gekregen heeft dan verwacht. Hierbij kunnen stembureaus welke in de voorspelling te veel ruis hebben om een goede controle te doen uitgesloten worden (in te stellen in `eml_types::SwitchedCandidateConfig`) | In `b.csv` de paren kandidaten waarvoor dit het geval is, gescheiden door een `, ` in het formaat: *"Mogelijke verwisseling op lijst `i` (`lijstnaam`). Kandidaat `j` had `v_j` stemmen maar verwachting was `e_j`. Kandidaat `k` had `v_k` stemmen maar verwachting was `e_k`"*
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "hcp"
version = "1.0"
version = "1.1"
description = "Hulpmiddel controle protocol. Scripts ter ondersteuning van onderdeel A van het Controleprotocol Centraal Stembureau."
requires-python = "~= 3.12.0"
readme = "README.md"
Expand Down
17 changes: 7 additions & 10 deletions src/hcp/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@
CURRENT_NEIGHBOURHOOD_FILE = "zip_to_neighbourhood_2024.parquet"

p = argparse.ArgumentParser()
p.add_argument("data_source", help="The election result to run HCP on.")
p.add_argument(
"data_source",
help="The election result to run HCP on. Can be a path to either a .zip file as output by OSV-2020U/Abacus or directly to a .eml.xml file.",
)
p.add_argument("--neighbourhoods", required=False)


def start():
"""Helper CLI tool to run HCP on either a .zip file as output by OSV-2020U"""
"""Helper CLI tool to run HCP on either a .zip file as output by OSV-2020U/Abacus
or directly on an .eml.xml file"""
args = p.parse_args()
extract_path = Path() / "tmp"

Expand Down Expand Up @@ -49,14 +53,11 @@ def start():
return

file_suffix = Path(args.data_source).suffix
# If we were supplied a zip file we unpack it and use the supplied odt
# If we were supplied a zip file we unpack it so we can access the xml
if file_suffix == ".zip":
with ZipFile(args.data_source, "r") as outer_zipfile:
try:
# Find and extract the .eml.xml and .odt file
odt_zipinfo = next(
f for f in outer_zipfile.filelist if f.filename.endswith(".odt")
)
inner_zipinfo = next(
f for f in outer_zipfile.filelist if f.filename.endswith(".zip")
)
Expand All @@ -67,7 +68,6 @@ def start():
if f.filename.endswith(".eml.xml")
)
inner_zipfile.extract(eml_zipinfo, extract_path)
outer_zipfile.extract(odt_zipinfo, extract_path)
except StopIteration:
print(
"""Zip file did not contain expected files! Make sure to specify the direct OSV-2020U output.
Expand All @@ -81,7 +81,6 @@ def start():
# Run HCP
create_csv_files(
path_to_xml=str(extract_path / eml_zipinfo.filename),
path_to_odt=str(extract_path / odt_zipinfo.filename),
path_to_neighbourhood_data=str(neighbourhood_file),
dest_a="a.csv",
dest_b="b.csv",
Expand All @@ -90,7 +89,6 @@ def start():

# Clean up after ourselves
remove(extract_path / eml_zipinfo.filename)
remove(extract_path / odt_zipinfo.filename)
try:
rmdir(extract_path)
except OSError as error:
Expand All @@ -102,7 +100,6 @@ def start():
elif file_suffix == ".xml":
create_csv_files(
path_to_xml=args.data_source,
path_to_odt=None,
path_to_neighbourhood_data=str(neighbourhood_file),
dest_a="a.csv",
dest_b="b.csv",
Expand Down
18 changes: 6 additions & 12 deletions src/hcp/csv_write.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"Stembureaunummer",
"Stembureaunaam",
]
PROTOCOL_VERSION = "TK2025"
PROTOCOL_VERSION = "GR2026"

ZIP_CODE_PATTERN = re.compile(r"\(\s*postcode:\s*\d{4}\s*[A-Z]{2}\s*\)")
STEMBUREAU_PREFIX_PATTERN = re.compile(r"^(Stembureau\s)+")
Expand Down Expand Up @@ -100,40 +100,34 @@ def _id_cols(
def write_csv_a(
check_results: Dict[str, CheckResult],
eml_metadata: EmlMetadata,
odt_used: bool,
csv_destination,
) -> None:
with open(csv_destination, "w", newline="", encoding="utf-8") as csvfile:
writer = csv.writer(csvfile, delimiter=";")
_write_header(
writer,
eml_metadata,
f"Stembureaus met geen verklaring voor telverschillen (odt {("gebruikt" if odt_used else "niet gebruikt")})",
"Stembureaus met niet onderzochte telverschillen",
)

writer.writerow(
HEADER_COLS
+ [
"Aantal geen verklaring voor verschil",
"Aantal ontbrekende verklaringen voor verschil",
"Niet onderzocht telverschil",
"Al herteld",
"Samenvatting",
]
)

for id, results in check_results.items():
inexplicable_difference = results.inexplicable_difference or None
explanation_sum_difference = results.explanation_sum_difference or None
difference = results.vote_difference
already_recounted = "ja" if results.already_recounted else None

if (
inexplicable_difference or explanation_sum_difference
) and not results.already_recounted:
if (difference > 0) and not results.already_recounted:
writer.writerow(
_id_cols(eml_metadata, id, "A")
+ [
inexplicable_difference,
explanation_sum_difference,
difference,
already_recounted,
results.summarise(SummaryType.A),
]
Expand Down
12 changes: 4 additions & 8 deletions src/hcp/eml.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,15 @@ class EML:
DIFF_VOTE_THRESHOLD_PCT: ClassVar[float] = 2.0
DIFF_VOTE_THRESHOLD: ClassVar[int] = 15

PARTY_DIFFERENCE_THRESHOLD_PCT: ClassVar[float] = 50.0
PARTY_DIFFERENCE_THRESHOLD_PCT: ClassVar[float] = 60.0

SWITCHED_CANDIDATE_CONFIG: ClassVar[SwitchedCandidateConfig] = (
SwitchedCandidateConfig(
minimum_reporting_units_municipality=2,
minimum_reporting_units_neighbourhood=5,
minimum_deviation_factor=5,
minimum_votes=15,
maximum_rmse=4.0,
)
)
# ---
Expand Down Expand Up @@ -70,12 +71,7 @@ def run_protocol(
for polling_station_id, polling_station in self.reporting_units_info.items():
check_result = CheckResult(
zero_votes=protocol_checks.check_zero_votes(polling_station),
inexplicable_difference=protocol_checks.check_inexplicable_difference(
polling_station
),
explanation_sum_difference=protocol_checks.check_explanation_sum_difference(
polling_station
),
vote_difference=protocol_checks.check_vote_difference(polling_station),
high_invalid_vote_percentage=protocol_checks.check_too_many_rejected_votes(
polling_station, "ongeldig", EML.INVALID_VOTE_THRESHOLD_PCT
),
Expand Down Expand Up @@ -103,7 +99,7 @@ def run_protocol(
reporting_neighbourhoods,
EML.SWITCHED_CANDIDATE_CONFIG,
),
already_recounted=False,
already_recounted=polling_station.has_recounted,
)

protocol_results[polling_station_id] = check_result
Expand Down
32 changes: 9 additions & 23 deletions src/hcp/eml_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ class ReportingUnitInfo:
uncounted_votes: Dict[str, int]
votes_per_party: Dict[PartyIdentifier, int]
votes_per_candidate: Dict[CandidateIdentifier, int]
has_recounted: bool = False


@dataclass
Expand Down Expand Up @@ -114,6 +115,7 @@ class SwitchedCandidateConfig:
minimum_reporting_units_neighbourhood: int
minimum_deviation_factor: int
minimum_votes: int
maximum_rmse: Optional[float]


class SummaryType(Enum):
Expand All @@ -128,8 +130,7 @@ class CheckResult:
"""

zero_votes: bool
inexplicable_difference: int
explanation_sum_difference: int
vote_difference: int
high_invalid_vote_percentage: Optional[float]
high_blank_vote_percentage: Optional[float]
high_vote_difference: Optional[VoteDifference]
Expand Down Expand Up @@ -162,33 +163,18 @@ def render(self, recounted: bool, summary_type: SummaryType) -> str:

result = "".join(self.content)
# Only add if we've recounted for type A (differences)
if summary_type == SummaryType.A:
result += f" Er is {'wel' if recounted else 'niet'} herteld."
if summary_type == SummaryType.A and not recounted:
result += """ Volgens het GSB is dit niet herteld of onderzocht."""

return result

sentence = Sentence()

if summary_type == SummaryType.A:
if self.inexplicable_difference and not self.explanation_sum_difference:
sentence.add(
"een onverklaard verschil tussen het aantal toegelaten kiezers en "
f"het aantal getelde stembiljetten van {self.inexplicable_difference}"
)
elif self.explanation_sum_difference and not self.inexplicable_difference:
sentence.add(
"een onverklaard verschil tussen het aantal toegelaten kiezers en het "
f"aantal getelde stembiljetten van {self.explanation_sum_difference}. "
"In het proces-verbaal tellen de verklaringen die gegeven zijn niet op tot "
"het verschil tussen het aantal toegelaten kiezers en het aantal getelde stembiljetten"
)
elif self.explanation_sum_difference and self.inexplicable_difference:
sentence.add(
"een onverklaard verschil tussen het aantal toegelaten kiezers en het aantal "
f"getelde stembiljetten van {self.inexplicable_difference + self.explanation_sum_difference}. "
f"In het proces-verbaal is ingevuld dat er {self.inexplicable_difference} keer geen verklaring "
"is voor het verschil. De verklaringen die gegeven zijn tellen niet op tot het totale verschil"
)
sentence.add(
"een verschil tussen het aantal toegelaten kiezers en "
f"het aantal getelde stembiljetten van {self.vote_difference}"
)

elif summary_type == SummaryType.B:
if self.zero_votes:
Expand Down
Loading