# The national herd at a glance

**Answer.** Uruguay's holders declared **12,265,269 cattle** on 30 June 2025 (11,583,321 beef and 681,948 dairy) and **4,799,066 sheep**. On 2024, cattle changed +1.05% and sheep -11.47%.

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

## Findings

- Uruguay's holders declared **12,265,269 cattle** on 30 June 2025 (11,583,321 beef and 681,948 dairy) and **4,799,066 sheep**. On 2024, cattle changed +1.05% and sheep -11.47%.
- **Florida** has the most cattle (1,117,097); Montevideo the fewest (2,736). The five largest departments hold 40.6% of the national herd.

## The notebook

Every year each livestock holder in Uruguay files a sworn declaration with
DICOSE (the Ministry of Livestock's control division) saying what animals are
on their land on 30 June. SNIG publishes the aggregates on the national
open-data catalogue, down to the *área de enumeración*, a census area a few
hundred square kilometres across. cow-tools turns those CSVs into an RDF Data
Cube with provenance, and this notebook asks the cube the first question
anyone asks: **how many animals are there?**

Every number below comes from a SPARQL query against the demo's own endpoint,
and every headline number is checked against the original CSV, re-read from
the bytes the cube was built from. If any check disagrees, this notebook
stops; it never prints a number it could not reproduce.

```python
# Setup: the client talks to https://cow-tools.sophia-labs.com unless
# COWTOOLS_BASE_URL says otherwise.
import altair as alt
import pandas as pd

import cowtools_client as cw
import cowtools_charts as cc

print("endpoint:", cw.BASE_URL, "· graph:", cw.COWS)
```

```text
endpoint: http://127.0.0.1:7795 · graph: uruguay-cows
```

## Animals by species

The cube keeps one observation per row of `datosanimales.csv`: a species in a
combination of department, census area, police section and a handful of
producer classifications. Its `cow:animalsCount` is the column DICOSE has
always used for stock, *propios dentro + ajenos dentro*: animals physically on
the holding, whoever owns them.

```python
# The query: sum the stock of every species, both years.
species = cw.sparql("""
SELECT ?year ?species (SUM(?n) AS ?animals) (SUM(?h) AS ?holder_rows) (COUNT(?obs) AS ?observations)
WHERE {
  GRAPH ?g {
    ?obs a cow:AnimalFlowObservation ;
         cow:year ?year ;
         cow:species ?species ;
         cow:animalsCount ?n .
    OPTIONAL { ?obs cow:holdersCount ?h }
  }
}
GROUP BY ?year ?species
ORDER BY ?year ?species
""")
species["code"] = species["species"].str.rsplit("/", n=1).str[-1]
species["label"] = species["code"].map(cw.SPECIES)
cc.show_table(species[["year", "code", "label", "animals", "observations"]].astype({"year": str}), name="species")
```

|   year |   code | label            | animals    | observations   |
|-------:|-------:|:-----------------|:-----------|:---------------|
|   2024 |      1 | BOVINOS          | 11,449,817 | 27,508         |
|   2024 |     11 | BOVINOS DE LECHE | 688,331    | 2,188          |
|   2024 |      2 | OVINOS           | 5,420,661  | 15,938         |
|   2024 |      3 | YEGUARIZOS       | 398,724    | 22,111         |
|   2024 |      4 | CAPRINOS         | 9,772      | 9,693          |
|   2024 |      5 | SUINOS           | 110,583    | 3,042          |
|   2025 |      1 | BOVINOS          | 11,583,321 | 27,151         |
|   2025 |     11 | BOVINOS DE LECHE | 681,948    | 2,104          |
|   2025 |      2 | OVINOS           | 4,799,066  | 15,882         |
|   2025 |      3 | YEGUARIZOS       | 405,813    | 22,080         |
|   2025 |      4 | CAPRINOS         | 10,415     | 8,722          |
|   2025 |      5 | SUINOS           | 117,040    | 3,157          |

Two species codes are cattle. DICOSE files dairy cattle (`11`, *bovinos de
leche*) apart from beef cattle (`1`), so "how many cattle" means the sum of
both. Sheep are `2`.

```python
wide = species.pivot(index="code", columns="year", values="animals").fillna(0).astype(int)
cattle = {y: int(wide.loc[cw.BEEF, y] + wide.loc[cw.DAIRY, y]) for y in (2024, 2025)}
sheep = {y: int(wide.loc[cw.SHEEP, y]) for y in (2024, 2025)}

cw.finding("headline",
    f"Uruguay's holders declared **{cattle[2025]:,} cattle** on 30 June 2025 "
    f"({int(wide.loc[cw.BEEF, 2025]):,} beef and {int(wide.loc[cw.DAIRY, 2025]):,} dairy) "
    f"and **{sheep[2025]:,} sheep**. On 2024, cattle changed {cattle[2025]/cattle[2024]-1:+.2%} "
    f"and sheep {sheep[2025]/sheep[2024]-1:+.2%}.")
```

> **Finding.** Uruguay's holders declared **12,265,269 cattle** on 30 June 2025 (11,583,321 beef and 681,948 dairy) and **4,799,066 sheep**. On 2024, cattle changed +1.05% and sheep -11.47%.

### Cross-check against the source

The independent path: open the original `datosanimales.csv` for each year
(verified byte for byte against the SHA-256 the cube recorded at ingest),
sum the `AnimalesPorEspeciePD_AD` column with pandas, and demand equality to
the head. Then compare with the figures the cow-tools WP-0 oracle fixed in
July (12,138,148 and 12,265,269 cattle; 4,799,066 sheep in 2025).

```python
for year in (2024, 2025):
    csv = cw.source_csv(year, "datosanimales.csv")
    stock = cw.num(csv["AnimalesPorEspeciePD_AD"]).groupby(csv["EspecieCodigo"]).sum()
    for code in wide.index:
        cw.check(f"{year} {cw.SPECIES[code].lower()}: cube = CSV", int(wide.loc[code, year]), int(stock.get(code, 0)),
                 source=f"datosanimales.csv {year}, column AnimalesPorEspeciePD_AD")
    rows = len(csv)
    cw.check(f"{year}: one observation per CSV row", int(species.query("year == @year")["observations"].sum()), rows,
             source=f"datosanimales.csv {year} has {rows:,} data rows")

cw.check("2024 cattle (WP-0 oracle)", cattle[2024], 12_138_148, source="cow-tools examples/wp0_oracle.rs (b)")
cw.check("2025 cattle (WP-0 oracle)", cattle[2025], 12_265_269, source="cow-tools examples/wp0_oracle.rs (b)")
cw.check("2025 sheep (WP-0 oracle)", sheep[2025], 4_799_066, source="cow-tools examples/wp0_oracle.rs (f)")
```

```text
PASS  2024 bovinos: cube = CSV: 11,449,817 vs 11,449,817 (exactly)  — datosanimales.csv 2024, column AnimalesPorEspeciePD_AD
PASS  2024 bovinos de leche: cube = CSV: 688,331 vs 688,331 (exactly)  — datosanimales.csv 2024, column AnimalesPorEspeciePD_AD
PASS  2024 ovinos: cube = CSV: 5,420,661 vs 5,420,661 (exactly)  — datosanimales.csv 2024, column AnimalesPorEspeciePD_AD
PASS  2024 yeguarizos: cube = CSV: 398,724 vs 398,724 (exactly)  — datosanimales.csv 2024, column AnimalesPorEspeciePD_AD
PASS  2024 caprinos: cube = CSV: 9,772 vs 9,772 (exactly)  — datosanimales.csv 2024, column AnimalesPorEspeciePD_AD
PASS  2024 suinos: cube = CSV: 110,583 vs 110,583 (exactly)  — datosanimales.csv 2024, column AnimalesPorEspeciePD_AD
PASS  2024: one observation per CSV row: 80,480 vs 80,480 (exactly)  — datosanimales.csv 2024 has 80,480 data rows
```

```text
PASS  2025 bovinos: cube = CSV: 11,583,321 vs 11,583,321 (exactly)  — datosanimales.csv 2025, column AnimalesPorEspeciePD_AD
PASS  2025 bovinos de leche: cube = CSV: 681,948 vs 681,948 (exactly)  — datosanimales.csv 2025, column AnimalesPorEspeciePD_AD
PASS  2025 ovinos: cube = CSV: 4,799,066 vs 4,799,066 (exactly)  — datosanimales.csv 2025, column AnimalesPorEspeciePD_AD
PASS  2025 yeguarizos: cube = CSV: 405,813 vs 405,813 (exactly)  — datosanimales.csv 2025, column AnimalesPorEspeciePD_AD
PASS  2025 caprinos: cube = CSV: 10,415 vs 10,415 (exactly)  — datosanimales.csv 2025, column AnimalesPorEspeciePD_AD
PASS  2025 suinos: cube = CSV: 117,040 vs 117,040 (exactly)  — datosanimales.csv 2025, column AnimalesPorEspeciePD_AD
PASS  2025: one observation per CSV row: 79,096 vs 79,096 (exactly)  — datosanimales.csv 2025 has 79,096 data rows
PASS  2024 cattle (WP-0 oracle): 12,138,148 vs 12,138,148 (exactly)  — cow-tools examples/wp0_oracle.rs (b)
PASS  2025 cattle (WP-0 oracle): 12,265,269 vs 12,265,269 (exactly)  — cow-tools examples/wp0_oracle.rs (b)
PASS  2025 sheep (WP-0 oracle): 4,799,066 vs 4,799,066 (exactly)  — cow-tools examples/wp0_oracle.rs (f)
```

### The chart

Horses, pigs and goats are in the declaration too, but DICOSE says plainly in
its data dictionary that their numbers are "included for reference, without
quality control". They are shown, greyed, for completeness.

```python
labels = {"1": "Cattle (beef)", "11": "Cattle (dairy)", "2": "Sheep", "3": "Horses", "5": "Pigs", "4": "Goats"}
bars = species.query("year == 2025").assign(
    name=lambda d: d["code"].map(labels),
    checked=lambda d: d["code"].isin(["1", "11", "2"]).map({True: "quality-controlled", False: "reference only"}),
)
base = alt.Chart(bars, title=alt.Title("Animals declared on 30 June 2025",
                                      subtitle="DICOSE sworn declarations, stock on the holding (PD_AD)")).encode(
    y=alt.Y("name:N", sort="-x", title=None),
    x=alt.X("animals:Q", title="animals", axis=alt.Axis(format="~s")),
    tooltip=[alt.Tooltip("name:N", title="species"), alt.Tooltip("animals:Q", format=",")],
)
chart = base.mark_bar(height=18).encode(
    color=alt.Color("checked:N", scale=alt.Scale(domain=["quality-controlled", "reference only"],
                                                  range=[cc.CATTLE, cc.MUTED]), title=None))
text = base.mark_text(align="left", dx=4).encode(text=alt.Text("animals:Q", format=","))
cc.show((chart + text).properties(width=520, height=200), "species-2025",
        alt_text="Horizontal bars: beef cattle 11.6 million, sheep 4.8 million, dairy cattle 0.68 million, horses, pigs and goats far smaller.")
```

![Horizontal bars: beef cattle 11.6 million, sheep 4.8 million, dairy cattle 0.68 million, horses, pigs and goats far smaller.](charts/species-2025.svg)

Chart data and encoding: [charts/species-2025.vl.json](charts/species-2025.vl.json) (Vega-Lite).

## Where the cattle are

The same observations grouped by department. Every observation carries its
department as an IRI from the DICOSE department codelist
(`urn:sophia:cow:department/<code>`); the map joins that code to Natural
Earth's outlines of the 19 departments.

```python
by_dept = cw.sparql("""
SELECT ?department ?year (SUM(?n) AS ?cattle)
WHERE {
  GRAPH ?g {
    ?obs a cow:AnimalFlowObservation ;
         cow:year ?year ;
         cow:department ?department ;
         cow:species ?species ;
         cow:animalsCount ?n .
    VALUES ?species { <urn:sophia:cow:species/1> <urn:sophia:cow:species/11> }
  }
}
GROUP BY ?department ?year
""")
by_dept["code"] = by_dept["department"].map(cw.dept_code)
depts = cw.departments()
cattle_dept = (by_dept.pivot(index="code", columns="year", values="cattle").reset_index()
               .merge(depts[["code", "name"]], on="code"))
cattle_dept.columns = ["code", "cattle_2024", "cattle_2025", "name"]
cattle_dept = cattle_dept.sort_values("cattle_2025", ascending=False)

cw.check("19 departments", len(cattle_dept), 19, source="DICOSE codigosdepartamentos.csv")
cw.check("2025 departments sum to the national total", int(cattle_dept["cattle_2025"].sum()), cattle[2025],
         source="same cube, two groupings")
cc.show_table(cattle_dept[["name", "cattle_2025", "cattle_2024"]], name="cattle-by-department")
```

```text
PASS  19 departments: 19 vs 19 (exactly)  — DICOSE codigosdepartamentos.csv
PASS  2025 departments sum to the national total: 12,265,269 vs 12,265,269 (exactly)  — same cube, two groupings
```

| name           | cattle_2025   | cattle_2024   |
|:---------------|:--------------|:--------------|
| Florida        | 1,117,097     | 1,095,226     |
| Tacuarembó     | 1,061,136     | 1,034,575     |
| Durazno        | 952,171       | 900,286       |
| Cerro Largo    | 950,412       | 957,379       |
| Salto          | 898,899       | 849,010       |
| Paysandú       | 801,512       | 809,798       |
| Artigas        | 742,198       | 730,165       |
| Rocha          | 682,644       | 688,313       |
| Lavalleja      | 654,451       | 644,438       |
| Rivera         | 639,917       | 608,407       |
| Treinta y Tres | 584,734       | 613,051       |
| Soriano        | 567,452       | 570,050       |
| Río Negro      | 549,369       | 555,782       |
| San José       | 541,254       | 548,346       |
| Colonia        | 537,066       | 551,213       |
| Flores         | 409,429       | 393,191       |
| Canelones      | 338,601       | 355,686       |
| Maldonado      | 234,191       | 230,533       |
| Montevideo     | 2,736         | 2,699         |

```python
top = cattle_dept.iloc[0]
small = cattle_dept.iloc[-1]
cw.finding("where",
    f"**{top['name']}** has the most cattle ({int(top['cattle_2025']):,}); "
    f"{small['name']} the fewest ({int(small['cattle_2025']):,}). "
    f"The five largest departments hold {cattle_dept['cattle_2025'].head(5).sum()/cattle[2025]:.1%} of the national herd.")
```

> **Finding.** **Florida** has the most cattle (1,117,097); Montevideo the fewest (2,736). The five largest departments hold 40.6% of the national herd.

```python
cc.show(
    cc.choropleth(cattle_dept, "cattle_2025", title="Cattle by department, 2025",
                  subtitle="beef + dairy, animals on the holding (PD_AD)", legend="cattle",
                  fmt=",.0f"),
    "cattle-map-2025", thumbnail=True,
    alt_text="Map of Uruguay's 19 departments shaded by cattle; the darkest are in the centre and north (Florida, Tacuarembó, Durazno, Cerro Largo), the lightest is Montevideo.")
print(cc.map_credit())
```

![Map of Uruguay's 19 departments shaded by cattle; the darkest are in the centre and north (Florida, Tacuarembó, Durazno, Cerro Largo), the lightest is Montevideo.](charts/cattle-map-2025.svg)

Chart data and encoding: [charts/cattle-map-2025.vl.json](charts/cattle-map-2025.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.
```

## The cross-check, against the curated tools

The demo also answers through its curated `cow_*` MCP tools, which run their
own SPARQL over the same cube. `cow_rank` ranks departments by a metric; the
sum of its rows must be the same national total.

```python
tools = cw.list_tools()
if "cow_rank" in tools:
    ranked = cw.tool("cow_rank", metric="bovine_count", entity="department", year=2025, direction="top", limit=20)
    rows = ranked.get("results", ranked) if isinstance(ranked, dict) else ranked
    rows = rows.get("rows", rows) if isinstance(rows, dict) else rows
    cw.check("cow_rank bovine_count 2025 sums to the national total",
             int(round(sum(float(r["value"]) for r in rows))), cattle[2025], source="cow_rank via /mcp")
else:
    print(f"NOT RUN: this endpoint has no cow_rank tool yet ({len(tools)} tools listed). "
          "The SPARQL and CSV checks above still hold.")
```

```text
PASS  cow_rank bovine_count 2025 sums to the national total: 12,265,269 vs 12,265,269 (exactly)  — cow_rank via /mcp
```

## Where every number came from

The queries this notebook ran, and every cross-check, with its source.

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

| check                                                 | got        | expected   | rule    | source                                                 |
|:------------------------------------------------------|:-----------|:-----------|:--------|:-------------------------------------------------------|
| 2024 bovinos: cube = CSV                              | 11,449,817 | 11,449,817 | exactly | datosanimales.csv 2024, column AnimalesPorEspeciePD_AD |
| 2024 bovinos de leche: cube = CSV                     | 688,331    | 688,331    | exactly | datosanimales.csv 2024, column AnimalesPorEspeciePD_AD |
| 2024 ovinos: cube = CSV                               | 5,420,661  | 5,420,661  | exactly | datosanimales.csv 2024, column AnimalesPorEspeciePD_AD |
| 2024 yeguarizos: cube = CSV                           | 398,724    | 398,724    | exactly | datosanimales.csv 2024, column AnimalesPorEspeciePD_AD |
| 2024 caprinos: cube = CSV                             | 9,772      | 9,772      | exactly | datosanimales.csv 2024, column AnimalesPorEspeciePD_AD |
| 2024 suinos: cube = CSV                               | 110,583    | 110,583    | exactly | datosanimales.csv 2024, column AnimalesPorEspeciePD_AD |
| 2024: one observation per CSV row                     | 80,480     | 80,480     | exactly | datosanimales.csv 2024 has 80,480 data rows            |
| 2025 bovinos: cube = CSV                              | 11,583,321 | 11,583,321 | exactly | datosanimales.csv 2025, column AnimalesPorEspeciePD_AD |
| 2025 bovinos de leche: cube = CSV                     | 681,948    | 681,948    | exactly | datosanimales.csv 2025, column AnimalesPorEspeciePD_AD |
| 2025 ovinos: cube = CSV                               | 4,799,066  | 4,799,066  | exactly | datosanimales.csv 2025, column AnimalesPorEspeciePD_AD |
| 2025 yeguarizos: cube = CSV                           | 405,813    | 405,813    | exactly | datosanimales.csv 2025, column AnimalesPorEspeciePD_AD |
| 2025 caprinos: cube = CSV                             | 10,415     | 10,415     | exactly | datosanimales.csv 2025, column AnimalesPorEspeciePD_AD |
| 2025 suinos: cube = CSV                               | 117,040    | 117,040    | exactly | datosanimales.csv 2025, column AnimalesPorEspeciePD_AD |
| 2025: one observation per CSV row                     | 79,096     | 79,096     | exactly | datosanimales.csv 2025 has 79,096 data rows            |
| 2024 cattle (WP-0 oracle)                             | 12,138,148 | 12,138,148 | exactly | cow-tools examples/wp0_oracle.rs (b)                   |
| 2025 cattle (WP-0 oracle)                             | 12,265,269 | 12,265,269 | exactly | cow-tools examples/wp0_oracle.rs (b)                   |
| 2025 sheep (WP-0 oracle)                              | 4,799,066  | 4,799,066  | exactly | cow-tools examples/wp0_oracle.rs (f)                   |
| 19 departments                                        | 19         | 19         | exactly | DICOSE codigosdepartamentos.csv                        |
| 2025 departments sum to the national total            | 12,265,269 | 12,265,269 | exactly | same cube, two groupings                               |
| cow_rank bovine_count 2025 sums to the national total | 12,265,269 | 12,265,269 | exactly | cow_rank via /mcp                                      |

```text
     kind        target  rows  \
0  sparql  uruguay-cows  12.0   
1  sparql  uruguay-cows  38.0   
2    tool      cow_rank   NaN   

                                          first line  
0  SELECT ?year ?species (SUM(?n) AS ?animals) (S...  
1      SELECT ?department ?year (SUM(?n) AS ?cattle)  
2  {"metric": "bovine_count", "entity": "departme...
```

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