---
title: "JMP service ladders for water, sanitation and hygiene"
subtitle: "WASH household survey, 2024"
author: "Cassion · data-analysis.cassion.dev"
format:
  html:
    toc: true
    code-fold: false
jupyter: python3
---

## What this produces

Every household placed on the three JMP service ladders — drinking water,
sanitation and hygiene — and coverage by district. These are the definitions the
SDG 6 indicators are reported against, so getting them right is the difference
between a figure a cluster will accept and one it will send back.

Reference figures from the dataset's quality notes: about 13% of households below
the Sphere minimum of 15 litres per person per day, about 39% over a 30-minute
round trip, open defecation about 13%, basic hygiene service about 34%.

Every dataset on this platform is synthetic. No real household is described.

## Setup

```{python}
import pandas as pd
import numpy as np

URL = (
    "https://data-analysis.cassion.dev/datasets/files/"
    "wash-household-survey-2024.v1.csv"
)

wash = pd.read_csv(URL, dtype={"household_id": "string", "community": "string"})
print(wash.shape)
wash.head()
```

## Normalise the district before grouping by it

One enumerator team wrote the Nord-Ouest district name four different ways. Group
without normalising and you get six districts instead of three, splitting the
worst-performing one into four pieces small enough to look unremarkable.

```{python}
print(wash["district"].value_counts())
```

```{python}
wash["district"] = (
    wash["district"].str.strip().str.lower().str.replace(" ", "-", regex=False)
)
print(wash["district"].value_counts())
```

Normalise before you group, every time. This is the single most common way a
district table quietly hides its worst result.

## The drinking water ladder

The ladder is **not a property of the source alone.** An improved source more
than 30 minutes round trip is *limited* service, not basic — and about a quarter
of these households sit on limited service for that reason and no other. An
analysis that classifies on source type only misses every one of them.

```{python}
IMPROVED = {
    "piped-into-dwelling", "piped-into-yard", "public-tap", "borehole",
    "protected-well", "protected-spring", "tanker-truck",
}
UNIMPROVED = {"unprotected-well", "unprotected-spring"}

def water_ladder(row):
    source = row["water_source"]
    if source == "surface-water":
        return "surface water"
    if source in UNIMPROVED:
        return "unimproved"
    if pd.isna(row["round_trip_minutes"]):
        return "improved, time unknown"
    return "basic" if row["round_trip_minutes"] <= 30 else "limited"

wash["water_service"] = wash.apply(water_ladder, axis=1)
(wash["water_service"].value_counts(normalize=True) * 100).round(1)
```

`safely managed` is deliberately absent. It requires the source to be on
premises, available when needed **and** free from contamination, and this survey
tests quality on only a third of households — so the top rung cannot be assigned
for most of the sample. Reporting "basic" and stopping there is honest;
inventing a safely-managed figure is not.

## The sanitation ladder

```{python}
IMPROVED_SANITATION = {
    "flush-to-sewer", "flush-to-septic", "vip-latrine", "pit-latrine-with-slab",
}

def sanitation_ladder(row):
    facility = row["sanitation_facility"]
    if facility == "open-defecation":
        return "open defecation"
    if facility not in IMPROVED_SANITATION:
        return "unimproved"
    return "limited" if row["shared_sanitation"] else "basic"

wash["sanitation_service"] = wash.apply(sanitation_ladder, axis=1)
(wash["sanitation_service"].value_counts(normalize=True) * 100).round(1)
```

Sharing is what separates basic from limited. A household with a perfectly good
VIP latrine shared with three others is on *limited* service, and a table built
on facility type alone will report it as basic.

## The hygiene ladder

```{python}
def hygiene_ladder(row):
    if row["handwashing_facility"] == "no-facility":
        return "no facility"
    return "basic" if row["soap_observed"] else "limited"

wash["hygiene_service"] = wash.apply(hygiene_ladder, axis=1)
(wash["hygiene_service"].value_counts(normalize=True) * 100).round(1)
```

Basic hygiene requires a facility **with soap and water present**, observed
rather than reported. The distinction matters: a third of these households have
a facility and no soap, and asking "do you wash your hands" would have counted
every one of them as compliant.

## Coverage by district

```{python}
def coverage(df, column, level):
    return (
        df.groupby("district")[column]
        .apply(lambda s: (s == level).mean() * 100)
        .round(1)
    )

table = pd.DataFrame({
    "basic water": coverage(wash, "water_service", "basic"),
    "basic sanitation": coverage(wash, "sanitation_service", "basic"),
    "basic hygiene": coverage(wash, "hygiene_service", "basic"),
    "open defecation": coverage(wash, "sanitation_service", "open defecation"),
    "households": wash.groupby("district").size(),
})
table.sort_values("basic water")
```

## The Sphere quantity standard

The ladder says nothing about quantity. Sphere sets a minimum of 15 litres per
person per day, and it is a separate question from whether the source is
improved.

```{python}
SPHERE_MINIMUM = 15

below = wash["litres_per_person_day"] < SPHERE_MINIMUM
print(f"below {SPHERE_MINIMUM} l/p/d: {below.mean():.1%}")
print(f"over a 30-minute round trip: {(wash['round_trip_minutes'] > 30).mean():.1%}")

pd.crosstab(
    wash["water_service"],
    below.map({True: "below Sphere", False: "at or above"}),
    normalize="index",
).round(3)
```

Households on *basic* service still fall below the Sphere minimum. Access and
quantity are different indicators and neither substitutes for the other.

## The unit errors nobody notices

Eleven records hold collection time in hours rather than minutes, and fourteen
hold litres for the whole household rather than per person. Both look entirely
plausible in isolation — a round trip of 2, or 40 litres a day — and only stand
out against household size.

```{python}
suspect_litres = (
    wash["litres_per_person_day"] > 60
) & wash["household_size"].notna()

wash.loc[suspect_litres, [
    "household_id", "household_size", "litres_per_person_day",
]].assign(
    implied_household_total=lambda d: d["litres_per_person_day"] * d["household_size"],
    as_if_household_total=lambda d: d["litres_per_person_day"] / d["household_size"],
).head(10)
```

Read the last column: divided by household size, these become ordinary values.
That is the signature of a per-household figure entered in a per-person column.
Flag them; do not silently rescale, because you cannot prove which reading the
enumerator meant.

## What to report

State the ladder rung, the denominator it rests on, and the quantity indicator
separately. And say which households could not be classified — the ones with no
collection time recorded are not "basic", they are unknown, and rolling them into
the basic count is how a coverage figure drifts upward without anyone deciding
that it should.
