# Dairy country

**Answer.** **Florida, San José, Colonia** produce 68.3% of Uruguay's milk (2,298.4 million litres in 2025, +1.67% on 2024). Florida makes the most: 26.5%.

Notebook: https://cow-tools.sophia-labs.com/notebooks/dairy-country/dairy-country.ipynb · Page: https://cow-tools.sophia-labs.com/notebooks/dairy-country/index.html · Data: Uruguay MGAP/SNIG DICOSE 2024–2025 (graph `uruguay-cows` at https://cow-tools.sophia-labs.com/mcp).

## Findings

- Of the 2,298,402,318 litres declared in 2025, **318,070 were goat's milk**, 60% of it from San José.
- **Florida, San José, Colonia** produce 68.3% of Uruguay's milk (2,298.4 million litres in 2025, +1.67% on 2024). Florida makes the most: 26.5%.
- Florida had **100,116 dairy cows** on 30 June 2025 by DICOSE's dairy convention (own animals, PD_PF), or 103,472 counting the animals on its holdings (PD_AD); 182,610 dairy cattle of all ages.
- **San José** produces 1,605 litres per hectare of livestock land, 2.5× Florida's 645: Florida has the volume, San José the intensity.

## The notebook

Uruguay's milk comes from a compact basin in the south-west, the *cuenca
lechera*: Florida, San José and Colonia, with Canelones, Soriano and Durazno
at its edges. DICOSE asks every dairy holder how many litres they produced in
the year and where the milk went (sold to industry, made into cheese on the
farm, drunk at home, fed to calves).

Three questions: how much milk, where, and how many dairy cows make it.
The third has a trap in it, and we fall into it on purpose.

```python
# Setup.
import altair as alt
import pandas as pd

import cowtools_client as cw
import cowtools_charts as cc

names = cc.department_names()
```

## How much milk, and where

`produccionleche.csv` becomes `cow:MilkProductionObservation`s carrying
`cow:milkLiters` (an exact decimal), the species, and a production type
(the destination of the milk).

```python
# The query: litres per department and species, both years.
milk = cw.sparql("""
SELECT ?year ?department ?species (SUM(?l) AS ?litres) (COUNT(?obs) AS ?observations)
WHERE {
  GRAPH ?g {
    ?obs a cow:MilkProductionObservation ;
         cow:year ?year ;
         cow:department ?department ;
         cow:species ?species ;
         cow:milkLiters ?l .
  }
}
GROUP BY ?year ?department ?species
""")
milk["code"] = milk["department"].map(cw.dept_code)
milk["name"] = milk["code"].map(names)
milk["species_code"] = milk["species"].map(cw.dept_code)
milk["litres"] = milk["litres"].astype(float)
by_species = milk.groupby(["year", "species_code"])["litres"].sum().unstack()
by_species
```

```text
species_code            11         4
year                                
2024          2.259792e+09  775272.0
2025          2.298084e+09  318070.0
```

Two species give milk in the declaration: dairy cattle (`11`) and, in a
handful of rows, **goats** (`4`). Uruguay's national milk total, as published,
includes a few hundred thousand litres of goat's milk. For the dairy-basin
shares below we keep both, as the published total does, and say so.

```python
total = milk.groupby("year")["litres"].sum()
goat = by_species.get("4", pd.Series(0, index=by_species.index)).fillna(0)
goat_dept = milk.query("year == 2025 and species_code == '4'").sort_values("litres", ascending=False)
cw.finding("goats",
    f"Of the {total[2025]:,.0f} litres declared in 2025, **{goat[2025]:,.0f} were goat's milk**, "
    f"{goat_dept.iloc[0]['litres'] / goat[2025]:.0%} of it from {goat_dept.iloc[0]['name']}.")
```

> **Finding.** Of the 2,298,402,318 litres declared in 2025, **318,070 were goat's milk**, 60% of it from San José.

```python
dept = (milk.groupby(["year", "code", "name"])["litres"].sum().reset_index()
        .pivot_table(index=["code", "name"], columns="year", values="litres").reset_index())
dept.columns = ["code", "name", "litres_2024", "litres_2025"]
dept["share_2025"] = dept["litres_2025"] / total[2025]
dept = dept.sort_values("litres_2025", ascending=False)
basin = dept.head(3)
cc.show_table(dept[["name", "litres_2025", "share_2025", "litres_2024"]].head(8), floatfmt=",.3f",
              name="milk-by-department")
```

| name      | litres_2025     |   share_2025 | litres_2024     |
|:----------|:----------------|-------------:|:----------------|
| Florida   | 608,116,907.000 |        0.265 | 589,491,996.000 |
| San José  | 520,036,099.000 |        0.226 | 506,304,257.000 |
| Colonia   | 441,387,725.000 |        0.192 | 437,896,008.000 |
| Durazno   | 212,599,351.000 |        0.092 | 205,096,846.000 |
| Soriano   | 132,200,132.000 |        0.058 | 131,464,129.000 |
| Canelones | 112,534,896.000 |        0.049 | 110,567,310.000 |
| Río Negro | 82,430,061.000  |        0.036 | 84,098,833.000  |
| Flores    | 56,347,358.000  |        0.025 | 60,006,073.000  |

### Cross-check against the source

The litres per department, re-summed from `produccionleche.csv` (both
years, every department, every species), must match the cube to the litre.
Then the shares the July 2026 expedition published: Florida 26.5%, San José
22.6%, Colonia 19.2%, together 68.3% of 2,298.4 million litres; national
milk +1.67% on 2024.

```python
for year in (2024, 2025):
    csv = cw.source_csv(year, "produccionleche.csv")
    s = cw.num(csv["Litros"]).groupby(csv["DepartamentoCodigo"]).sum()
    cube = milk.query("year == @year").groupby("code")["litres"].sum()
    diff = (cube - s.reindex(cube.index)).abs().max()
    cw.check(f"{year} litres per department: cube = CSV (largest difference, litres)", float(diff), 0.0,
             source=f"produccionleche.csv {year}, column Litros")
    cw.check(f"{year} national litres: cube = CSV", float(total[year]), float(s.sum()), source="produccionleche.csv")

share = dept.set_index("name")["share_2025"]
cw.check("Florida share of milk", round(share["Florida"], 3), 0.265, tol=0.0005, source="expedition 2026-07-05")
cw.check("San José share of milk", round(share["San José"], 3), 0.226, tol=0.0005, source="expedition 2026-07-05")
cw.check("Colonia share of milk", round(share["Colonia"], 3), 0.192, tol=0.0005, source="expedition 2026-07-05")
cw.check("2025 national milk, millions of litres", round(total[2025] / 1e6, 1), 2298.4, tol=0.05, source="expedition 2026-07-05")
cw.check("milk 2024 → 2025", round(total[2025] / total[2024] - 1, 4), 0.0167, tol=0.00005, source="expedition 2026-07-05: +1.67%")

cw.finding("headline",
    f"**{', '.join(basin['name'])}** produce {basin['share_2025'].sum():.1%} of Uruguay's milk "
    f"({total[2025]/1e6:,.1f} million litres in 2025, {total[2025]/total[2024]-1:+.2%} on 2024). "
    f"{basin.iloc[0]['name']} makes the most: {basin.iloc[0]['share_2025']:.1%}.")
```

```text
PASS  2024 litres per department: cube = CSV (largest difference, litres): 0 vs 0 (exactly)  — produccionleche.csv 2024, column Litros
PASS  2024 national litres: cube = CSV: 2,260,567,177 vs 2,260,567,177 (exactly)  — produccionleche.csv
PASS  2025 litres per department: cube = CSV (largest difference, litres): 0 vs 0 (exactly)  — produccionleche.csv 2025, column Litros
PASS  2025 national litres: cube = CSV: 2,298,402,318 vs 2,298,402,318 (exactly)  — produccionleche.csv
PASS  Florida share of milk: 0.265 vs 0.265 (within ±0.0005)  — expedition 2026-07-05
PASS  San José share of milk: 0.226 vs 0.226 (within ±0.0005)  — expedition 2026-07-05
PASS  Colonia share of milk: 0.192 vs 0.192 (within ±0.0005)  — expedition 2026-07-05
PASS  2025 national milk, millions of litres: 2,298 vs 2,298 (within ±0.05)  — expedition 2026-07-05
PASS  milk 2024 → 2025: 0.0167 vs 0.0167 (within ±5e-05)  — expedition 2026-07-05: +1.67%
```

> **Finding.** **Florida, San José, Colonia** produce 68.3% of Uruguay's milk (2,298.4 million litres in 2025, +1.67% on 2024). Florida makes the most: 26.5%.

```python
cc.show(cc.choropleth(dept, "share_2025", title="Share of Uruguay's milk, 2025",
                      subtitle="litres declared to DICOSE, all destinations", legend="share of national milk",
                      fmt=".1%"),
        "milk-share-map", thumbnail=True,
        alt_text="Map: milk is concentrated in Florida, San José and Colonia in the south-west; the north and east produce almost none.")
print(cc.map_credit())
```

![Map: milk is concentrated in Florida, San José and Colonia in the south-west; the north and east produce almost none.](charts/milk-share-map.svg)

Chart data and encoding: [charts/milk-share-map.vl.json](charts/milk-share-map.vl.json) (Vega-Lite).

```text
Map: Natural Earth 1:10m admin-1 (public domain, v5.1.2), joined on the DICOSE department code. Made with Natural Earth.
```

## How many dairy cows? (the trap)

Ask "how many dairy cows are in Florida?" and the cube can give you three
honest answers, because the question is ambiguous twice over:

1. **dairy cattle or dairy cows?** Species `11` counts bulls, heifers and
   calves too. The cows are two categories: *vacas en ordeñe* (milking) and
   *vacas secas* (dry).
2. **which stock?** DICOSE publishes two stock columns. `PD_AD` (own and
   others' animals *on* the holding) is the traditional stock. `PD_PF` (the
   holder's *own* animals, here or elsewhere) is, in DICOSE's own data
   dictionary, the one "used to measure livestock associated with dairy
   production".

The cube carries both (`cow:animalsCount` and `cow:animalsCountPf`) on every
category observation. Here they are side by side.

```python
# The query: Florida's dairy cattle by category, both stock columns, 2025.
cats = cw.sparql("""
SELECT ?category (SUM(?n) AS ?pd_ad) (SUM(?pf) AS ?pd_pf)
WHERE {
  GRAPH ?g {
    ?obs a cow:AnimalCategoryStockObservation ;
         cow:year "2025"^^xsd:gYear ;
         cow:department <urn:sophia:cow:department/7> ;
         cow:species <urn:sophia:cow:species/11> ;
         cow:animalCategory ?category ;
         cow:animalsCount ?n .
    OPTIONAL { ?obs cow:animalsCountPf ?pf }
  }
}
GROUP BY ?category
""")
# Labels in their own query: the codelists live in one named graph per year, so joining them into the
# sum above would count every animal twice (once per year's codelist).
labels = cw.sparql("""
SELECT DISTINCT ?category ?label
WHERE { GRAPH ?c { ?category skos:prefLabel ?label }
        FILTER(STRSTARTS(STR(?category), "urn:sophia:cow:animal-category/11/")) }
""").drop_duplicates("category")
cats = cats.merge(labels, on="category", how="left")
cats["code"] = cats["category"].map(cw.dept_code)
cats = cats.sort_values("code", key=lambda s: s.astype(int))
cows = cats[cats["code"].isin(["2", "3"])]
answer = {
    "dairy cattle, PD_AD": int(cats["pd_ad"].sum()),
    "dairy cows (milking + dry), PD_AD": int(cows["pd_ad"].sum()),
    "dairy cows (milking + dry), PD_PF": int(cows["pd_pf"].sum()),
}
cc.show_table(cats[["code", "label", "pd_ad", "pd_pf"]], name="florida-dairy-categories")
pd.Series(answer, name="Florida, 2025")
```

|   code | label                                       | pd_ad   | pd_pf   |
|-------:|:--------------------------------------------|:--------|:--------|
|      1 | 1) TOROS                                    | 1,026   | 1,080   |
|      2 | 2) VACAS EN ORDEÑE                          | 81,547  | 79,048  |
|      3 | 3) VACAS SECAS                              | 21,925  | 21,068  |
|      4 | 4) VAQUILLONAS DE MÁS DE 2 AÑOS SIN ENTORAR | 7,132   | 7,841   |
|      5 | 5) VAQUILLONAS DE 1 Ó 2 AÑOS                | 25,899  | 27,478  |
|      6 | 6) TERNEROS DE MENOS DE 1 AÑO               | 11,059  | 11,079  |
|      7 | 7) TERNERAS DE MENOS DE 1 AÑO               | 34,022  | 34,282  |

```text
dairy cattle, PD_AD                  182610
dairy cows (milking + dry), PD_AD    103472
dairy cows (milking + dry), PD_PF    100116
Name: Florida, 2025, dtype: int64
```

```python
csv = cw.source_csv(2025, "datosanimalesdetallados.csv")
fl = csv[(csv["DepartamentoCodigo"] == "7") & (csv["EspecieCodigo"] == "11")]
ad = cw.num(fl["AnimalesPorCategoriaPD_AD"]).groupby(fl["CategoriaCodigo"]).sum()
pf = cw.num(fl["AnimalesPorCategoriaPD_PF"]).groupby(fl["CategoriaCodigo"]).sum()
cw.check("Florida dairy cattle 2025 (PD_AD): cube = CSV", answer["dairy cattle, PD_AD"], int(ad.sum()),
         source="datosanimalesdetallados.csv 2025")
cw.check("Florida dairy cows 2025 (PD_AD): cube = CSV", answer["dairy cows (milking + dry), PD_AD"],
         int(ad[["2", "3"]].sum()), source="datosanimalesdetallados.csv 2025, categories 2 and 3")
cw.check("Florida dairy cows 2025 (PD_PF): cube = CSV", answer["dairy cows (milking + dry), PD_PF"],
         int(pf[["2", "3"]].sum()), source="datosanimalesdetallados.csv 2025, categories 2 and 3")
cw.finding("florida-cows",
    f"Florida had **{answer['dairy cows (milking + dry), PD_PF']:,} dairy cows** on 30 June 2025 by DICOSE's "
    f"dairy convention (own animals, PD_PF), or {answer['dairy cows (milking + dry), PD_AD']:,} counting the "
    f"animals on its holdings (PD_AD); {answer['dairy cattle, PD_AD']:,} dairy cattle of all ages.")
```

```text
PASS  Florida dairy cattle 2025 (PD_AD): cube = CSV: 182,610 vs 182,610 (exactly)  — datosanimalesdetallados.csv 2025
PASS  Florida dairy cows 2025 (PD_AD): cube = CSV: 103,472 vs 103,472 (exactly)  — datosanimalesdetallados.csv 2025, categories 2 and 3
PASS  Florida dairy cows 2025 (PD_PF): cube = CSV: 100,116 vs 100,116 (exactly)  — datosanimalesdetallados.csv 2025, categories 2 and 3
```

> **Finding.** Florida had **100,116 dairy cows** on 30 June 2025 by DICOSE's dairy convention (own animals, PD_PF), or 103,472 counting the animals on its holdings (PD_AD); 182,610 dairy cattle of all ages.

## Who is the most intensive?

Florida milks the most; San José milks the hardest. Two derived ratios,
computed here from measured observations: litres per holder of a dairy
declaration, and litres per hectare of the department's livestock land
(`cow:livestockHectares`, from `datosgenerales.csv`). The second is a
department-wide yardstick, not a dairy-farm yield: it divides the milk by all
the land grazed by anything.

```python
# The query: holders declaring milk, and livestock hectares, per department, 2025.
ratios = cw.sparql("""
SELECT ?department (SUM(?h) AS ?milk_holders)
WHERE {
  GRAPH ?g {
    ?obs a cow:MilkProductionObservation ; cow:year "2025"^^xsd:gYear ;
         cow:department ?department ; cow:holdersCount ?h .
  }
}
GROUP BY ?department
""").merge(cw.sparql("""
SELECT ?department (SUM(?ha) AS ?livestock_ha)
WHERE {
  GRAPH ?g {
    ?obs a cow:GeneralHoldingObservation ; cow:year "2025"^^xsd:gYear ;
         cow:department ?department ; cow:livestockHectares ?ha .
  }
}
GROUP BY ?department
"""), on="department")
ratios["code"] = ratios["department"].map(cw.dept_code)
ratios = ratios.merge(dept[["code", "name", "litres_2025"]], on="code")
ratios["livestock_ha"] = ratios["livestock_ha"].astype(float)
ratios["litres_per_ha"] = ratios["litres_2025"] / ratios["livestock_ha"]
ratios = ratios.sort_values("litres_per_ha", ascending=False)
cc.show_table(ratios[["name", "litres_per_ha", "litres_2025", "livestock_ha"]].head(6), floatfmt=",.0f",
              name="intensity")
```

| name      | litres_per_ha   | litres_2025   | livestock_ha   |
|:----------|:----------------|:--------------|:---------------|
| San José  | 1,605           | 520,036,099   | 324,103        |
| Colonia   | 1,277           | 441,387,725   | 345,775        |
| Florida   | 645             | 608,116,907   | 942,195        |
| Canelones | 462             | 112,534,896   | 243,465        |
| Soriano   | 268             | 132,200,132   | 493,507        |
| Durazno   | 217             | 212,599,351   | 981,607        |

`milk_holders` sums a count that DICOSE repeats on every production-type row
(a holder who sells milk and also makes cheese is counted on both rows), so
it over-counts people; we do not divide by it. The hectare ratio has no such
problem: `datosgenerales.csv` has one row per holding class.

```python
r = ratios.set_index("name")
cw.check("San José litres per livestock hectare", round(r.loc["San José", "litres_per_ha"]), 1604, tol=1,
         source="expedition 2026-07-05: 1,604 L/ha")
csvg = cw.source_csv(2025, "datosgenerales.csv")
ha = cw.num(csvg["SuperficieGanadera"]).groupby(csvg["DepartamentoCodigo"]).sum()
cw.check("San José livestock hectares: cube = CSV", round(float(r.loc["San José", "livestock_ha"]), 2),
         round(float(ha["16"]), 2), source="datosgenerales.csv 2025, SuperficieGanadera")
ratio = r.loc["San José", "litres_per_ha"] / r.loc["Florida", "litres_per_ha"]
cw.finding("intensity",
    f"**San José** produces {r.loc['San José', 'litres_per_ha']:,.0f} litres per hectare of livestock land, "
    f"{ratio:.1f}× Florida's {r.loc['Florida', 'litres_per_ha']:,.0f}: Florida has the volume, San José the intensity.")
```

```text
PASS  San José litres per livestock hectare: 1,605 vs 1,604 (within ±1)  — expedition 2026-07-05: 1,604 L/ha
PASS  San José livestock hectares: cube = CSV: 324,103 vs 324,103 (exactly)  — datosgenerales.csv 2025, SuperficieGanadera
```

> **Finding.** **San José** produces 1,605 litres per hectare of livestock land, 2.5× Florida's 645: Florida has the volume, San José the intensity.

```python
base = alt.Chart(ratios.head(8), title=alt.Title("Litres of milk per hectare of livestock land, 2025",
                                                 subtitle="department milk ÷ all livestock hectares (a yardstick, not a farm yield)")).encode(
    y=alt.Y("name:N", sort="-x", title=None),
    x=alt.X("litres_per_ha:Q", title="litres per hectare"),
    tooltip=["name:N", alt.Tooltip("litres_per_ha:Q", format=",.0f")],
)
bar = base.mark_bar(height=16, color=cc.DAIRY)
cc.show((bar + base.mark_text(align="left", dx=4).encode(text=alt.Text("litres_per_ha:Q", format=",.0f")))
        .properties(width=480, height=190),
        "litres-per-ha",
        alt_text="Bars: San José leads at about 1,600 litres per livestock hectare, then Colonia (about 1,280) and Florida (about 650).")
```

![Bars: San José leads at about 1,600 litres per livestock hectare, then Colonia (about 1,280) and Florida (about 650).](charts/litres-per-ha.svg)

Chart data and encoding: [charts/litres-per-ha.vl.json](charts/litres-per-ha.vl.json) (Vega-Lite).

## Where every number came from

```python
cc.show_table(cw.checks()[["check", "got", "expected", "rule", "source"]], name="checks")
cw.provenance()
```

| check                                                               | got              | expected         | rule           | source                                               |
|:--------------------------------------------------------------------|:-----------------|:-----------------|:---------------|:-----------------------------------------------------|
| 2024 litres per department: cube = CSV (largest difference, litres) | 0.00             | 0.00             | exactly        | produccionleche.csv 2024, column Litros              |
| 2024 national litres: cube = CSV                                    | 2,260,567,177.00 | 2,260,567,177.00 | exactly        | produccionleche.csv                                  |
| 2025 litres per department: cube = CSV (largest difference, litres) | 0.00             | 0.00             | exactly        | produccionleche.csv 2025, column Litros              |
| 2025 national litres: cube = CSV                                    | 2,298,402,318.00 | 2,298,402,318.00 | exactly        | produccionleche.csv                                  |
| Florida share of milk                                               | 0.27             | 0.27             | within ±0.0005 | expedition 2026-07-05                                |
| San José share of milk                                              | 0.23             | 0.23             | within ±0.0005 | expedition 2026-07-05                                |
| Colonia share of milk                                               | 0.19             | 0.19             | within ±0.0005 | expedition 2026-07-05                                |
| 2025 national milk, millions of litres                              | 2,298.40         | 2,298.40         | within ±0.05   | expedition 2026-07-05                                |
| milk 2024 → 2025                                                    | 0.02             | 0.02             | within ±5e-05  | expedition 2026-07-05: +1.67%                        |
| Florida dairy cattle 2025 (PD_AD): cube = CSV                       | 182,610.00       | 182,610.00       | exactly        | datosanimalesdetallados.csv 2025                     |
| Florida dairy cows 2025 (PD_AD): cube = CSV                         | 103,472.00       | 103,472.00       | exactly        | datosanimalesdetallados.csv 2025, categories 2 and 3 |
| Florida dairy cows 2025 (PD_PF): cube = CSV                         | 100,116.00       | 100,116.00       | exactly        | datosanimalesdetallados.csv 2025, categories 2 and 3 |
| San José litres per livestock hectare                               | 1,605.00         | 1,604.00         | within ±1      | expedition 2026-07-05: 1,604 L/ha                    |
| San José livestock hectares: cube = CSV                             | 324,103.30       | 324,103.30       | exactly        | datosgenerales.csv 2025, SuperficieGanadera          |

```text
     kind        target  rows  \
0  sparql  uruguay-cows    50   
1  sparql  uruguay-cows     7   
2  sparql  uruguay-cows     7   
3  sparql  uruguay-cows    19   
4  sparql  uruguay-cows    19   

                                          first line  
0  SELECT ?year ?department ?species (SUM(?l) AS ...  
1  SELECT ?category (SUM(?n) AS ?pd_ad) (SUM(?pf)...  
2                   SELECT DISTINCT ?category ?label  
3      SELECT ?department (SUM(?h) AS ?milk_holders)  
4     SELECT ?department (SUM(?ha) AS ?livestock_ha)
```

```python
cw.save()
```
