# The herd ledger

**Answer.** Uruguay's cattle books balance only if **2,300,543 head left the farms** between the 2024 and 2025 declarations: **18.95% of the opening herd**. Calves born (2,697,988) outran deaths (247,498), home slaughter (22,826) and the herd's own growth (+127,121).

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

## Findings

- Uruguay's cattle books balance only if **2,300,543 head left the farms** between the 2024 and 2025 declarations: **18.95% of the opening herd**. Calves born (2,697,988) outran deaths (247,498), home slaughter (22,826) and the herd's own growth (+127,121).
- The July 2026 expedition's figure for Rocha (21.4%) carried the all-species bug; species-honest, Rocha's cattle offtake is **25.4%** of its opening herd.
- By department the books run from **Treinta y Tres**, which shipped out 26.2% of its opening herd, to San José, 14.2%. Montevideo (a few thousand head) is left out of the extremes.

## The notebook

A herd can be kept like a set of books. Opening stock, plus calves born,
minus animals that died, minus animals slaughtered for the farm's own table,
should equal closing stock, *unless animals left*. Whatever is left over is
**offtake**: cattle sold to the meat-packing plants or exported on the hoof.
DICOSE does not publish slaughter in this dataset, but it does publish the
other four lines, so the fifth can be inferred.

    offtake = births − deaths − own consumption − (closing stock − opening stock)

Opening stock is the 2024 declaration (30 June 2024); closing stock is the
2025 one; the flows (births, deaths, consumption) are those declared in 2025
for the year in between.

This is the analysis that caught cow-tools out in July 2026. Births are only
declared for cattle, but deaths and consumption are declared for every
species; adding the sheep's deaths to the cattle's books made the extraction
rate look 30% too low. The curated metrics are now species-honest
(`bovine_births_count` and friends), and this notebook keeps to cattle by
construction: species `1` and `11` only.

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

import cowtools_client as cw
import cowtools_charts as cc

names = cc.department_names()
```

```python
# The query: cattle stock and flows per department, both years.
q = cw.sparql("""
SELECT ?year ?department
       (SUM(?n) AS ?stock) (SUM(?b) AS ?births) (SUM(?d) AS ?deaths) (SUM(?c) AS ?consumption)
WHERE {
  GRAPH ?g {
    ?obs a cow:AnimalFlowObservation ; cow:year ?year ; cow:department ?department ;
         cow:species ?s ; cow:animalsCount ?n .
    VALUES ?s { <urn:sophia:cow:species/1> <urn:sophia:cow:species/11> }
    OPTIONAL { ?obs cow:birthsCount ?b }
    OPTIONAL { ?obs cow:deathsCount ?d }
    OPTIONAL { ?obs cow:consumptionCount ?c }
  }
}
GROUP BY ?year ?department
""")
q["code"] = q["department"].map(cw.dept_code)
for c in ("births", "deaths", "consumption"):
    q[c] = q[c].fillna(0).astype(int)
n = q.groupby("year")[["stock", "births", "deaths", "consumption"]].sum()
n
```

```text
         stock   births  deaths  consumption
year                                        
2024  12138148  2364279  264496        23069
2025  12265269  2697988  247498        22826
```

```python
opening, closing = int(n.loc[2024, "stock"]), int(n.loc[2025, "stock"])
b, d, c = (int(n.loc[2025, k]) for k in ("births", "deaths", "consumption"))
offtake = b - d - c - (closing - opening)
rate = offtake / opening
ledger = pd.DataFrame([
    ("opening stock, 30 June 2024", opening),
    ("+ calves born", b),
    ("− deaths", -d),
    ("− slaughtered for own consumption", -c),
    ("− closing stock, 30 June 2025", -closing),
    ("= offtake (left the farms)", offtake),
], columns=["line", "head"])
cc.show_table(ledger, name="ledger")
```

| line                              | head        |
|:----------------------------------|:------------|
| opening stock, 30 June 2024       | 12,138,148  |
| + calves born                     | 2,697,988   |
| − deaths                          | -247,498    |
| − slaughtered for own consumption | -22,826     |
| − closing stock, 30 June 2025     | -12,265,269 |
| = offtake (left the farms)        | 2,300,543   |

### Cross-check against the source, and against the July oracle

```python
for year in (2024, 2025):
    csv = cw.source_csv(year, "datosanimales.csv")
    bov = csv[csv["EspecieCodigo"].isin(["1", "11"])]
    for col, key in [("AnimalesPorEspeciePD_AD", "stock"), ("AnimalesPorEspecieNacimientosPD_AD", "births"),
                     ("AnimalesPorEspecieMortandadPD_AD", "deaths"), ("AnimalesPorEspecieConsumoPD_AD", "consumption")]:
        cw.check(f"{year} cattle {key}: cube = CSV", int(n.loc[year, key]), int(cw.num(bov[col]).sum()),
                 source=f"datosanimales.csv {year}, {col}")
cw.check("offtake 2024 → 2025, head", offtake, 2_300_543, source="cow-tools WP-0 oracle (e), 2026-07-06")
cw.check("extraction rate", round(rate, 4), 0.1895, tol=0.00005, source="cow-tools WP-0 oracle (e): 18.95%")
```

```text
PASS  2024 cattle stock: cube = CSV: 12,138,148 vs 12,138,148 (exactly)  — datosanimales.csv 2024, AnimalesPorEspeciePD_AD
PASS  2024 cattle births: cube = CSV: 2,364,279 vs 2,364,279 (exactly)  — datosanimales.csv 2024, AnimalesPorEspecieNacimientosPD_AD
PASS  2024 cattle deaths: cube = CSV: 264,496 vs 264,496 (exactly)  — datosanimales.csv 2024, AnimalesPorEspecieMortandadPD_AD
PASS  2024 cattle consumption: cube = CSV: 23,069 vs 23,069 (exactly)  — datosanimales.csv 2024, AnimalesPorEspecieConsumoPD_AD
PASS  2025 cattle stock: cube = CSV: 12,265,269 vs 12,265,269 (exactly)  — datosanimales.csv 2025, AnimalesPorEspeciePD_AD
PASS  2025 cattle births: cube = CSV: 2,697,988 vs 2,697,988 (exactly)  — datosanimales.csv 2025, AnimalesPorEspecieNacimientosPD_AD
```

```text
PASS  2025 cattle deaths: cube = CSV: 247,498 vs 247,498 (exactly)  — datosanimales.csv 2025, AnimalesPorEspecieMortandadPD_AD
PASS  2025 cattle consumption: cube = CSV: 22,826 vs 22,826 (exactly)  — datosanimales.csv 2025, AnimalesPorEspecieConsumoPD_AD
PASS  offtake 2024 → 2025, head: 2,300,543 vs 2,300,543 (exactly)  — cow-tools WP-0 oracle (e), 2026-07-06
PASS  extraction rate: 0.1895 vs 0.1895 (within ±5e-05)  — cow-tools WP-0 oracle (e): 18.95%
```

A sanity bound from outside the dataset: the July 2026 expedition set this
against INAC's figures for cattle slaughtered plus cattle exported live,
roughly 2.2 to 2.5 million head a year. The inferred offtake sits inside that
range: the declarations are honest enough that a stranger's arithmetic
recovers the country's actual metabolism.

```python
cw.finding("headline",
    f"Uruguay's cattle books balance only if **{offtake:,} head left the farms** between the 2024 and 2025 "
    f"declarations: **{rate:.2%} of the opening herd**. Calves born ({b:,}) outran deaths ({d:,}), home slaughter "
    f"({c:,}) and the herd's own growth ({closing - opening:+,}).")
```

> **Finding.** Uruguay's cattle books balance only if **2,300,543 head left the farms** between the 2024 and 2025 declarations: **18.95% of the opening herd**. Calves born (2,697,988) outran deaths (247,498), home slaughter (22,826) and the herd's own growth (+127,121).

```python
water = pd.DataFrame([
    ("opening 2024", 0, opening, "stock"), ("births", opening, opening + b, "in"),
    ("deaths", opening + b - d, opening + b, "out"), ("own consumption", opening + b - d - c, opening + b - d, "out"),
    ("offtake", closing, opening + b - d - c, "out"), ("closing 2025", 0, closing, "stock"),
], columns=["step", "start", "end", "kind"])
water["order"] = range(len(water))
wf = alt.Chart(water, title=alt.Title("Uruguay's cattle ledger, 30 June 2024 → 30 June 2025",
                                      subtitle="head of cattle (beef + dairy); offtake is inferred")).mark_bar(size=34).encode(
    x=alt.X("step:N", sort=alt.SortField("order"), title=None, axis=alt.Axis(labelAngle=0)),
    y=alt.Y("start:Q", title="head", axis=alt.Axis(format="~s"), scale=alt.Scale(zero=False, domain=[9_000_000, 15_500_000])),
    y2="end:Q",
    color=alt.Color("kind:N", scale=alt.Scale(domain=["stock", "in", "out"], range=[cc.CATTLE, cc.DAIRY, cc.SHEEP]),
                    title=None),
    tooltip=["step:N", alt.Tooltip("start:Q", format=","), alt.Tooltip("end:Q", format=",")],
).properties(width=540, height=300)
cc.show(wf, "ledger-waterfall",
        alt_text="Waterfall: 12.14 million opening stock, plus 2.70 million births, minus small deaths and home slaughter, minus 2.30 million offtake, gives 12.27 million closing stock.")
```

![Waterfall: 12.14 million opening stock, plus 2.70 million births, minus small deaths and home slaughter, minus 2.30 million offtake, gives 12.27 million closing stock.](charts/ledger-waterfall.svg)

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

## Department by department

The same books per department. A department's offtake also includes cattle
sold to *other departments* (store calves going west to be finished), so a
breeding department's "extraction" is high and a finishing department's low.
That is the herd-structure notebook's story told in flows.

```python
dep = q.pivot_table(index="code", columns="year", values=["stock", "births", "deaths", "consumption"])
dd = pd.DataFrame({
    "opening": dep[("stock", 2024)], "closing": dep[("stock", 2025)], "births": dep[("births", 2025)],
    "deaths": dep[("deaths", 2025)], "consumption": dep[("consumption", 2025)],
})
dd["offtake"] = dd["births"] - dd["deaths"] - dd["consumption"] - (dd["closing"] - dd["opening"])
dd["rate"] = dd["offtake"] / dd["opening"]
dd = dd.reset_index()
dd["name"] = dd["code"].map(names)
dd = dd.sort_values("rate", ascending=False)
cw.check("department offtakes sum to the national offtake", int(dd["offtake"].sum()), offtake, source="same books, two groupings")
# Every department's books, re-derived from the CSVs.
a24 = cw.source_csv(2024, "datosanimales.csv")
a25 = cw.source_csv(2025, "datosanimales.csv")
b24 = a24[a24["EspecieCodigo"].isin(["1", "11"])]
b25 = a25[a25["EspecieCodigo"].isin(["1", "11"])]
g = lambda d, col: cw.num(d[col]).groupby(d["DepartamentoCodigo"]).sum()
src_off = (g(b25, "AnimalesPorEspecieNacimientosPD_AD") - g(b25, "AnimalesPorEspecieMortandadPD_AD")
           - g(b25, "AnimalesPorEspecieConsumoPD_AD") - (g(b25, "AnimalesPorEspeciePD_AD") - g(b24, "AnimalesPorEspeciePD_AD")))
cw.check("department offtakes: cube = CSV (largest difference, head)",
         int((dd.set_index("code")["offtake"] - src_off.reindex(dd["code"]).values).abs().max()), 0,
         source="datosanimales.csv 2024 and 2025, cattle only")
cc.show_table(dd[["name", "opening", "births", "offtake", "rate"]].query("name != 'Montevideo'"), floatfmt=".3f",
              name="department-ledger")
```

```text
PASS  department offtakes sum to the national offtake: 2,300,543 vs 2,300,543 (exactly)  — same books, two groupings
```

```text
PASS  department offtakes: cube = CSV (largest difference, head): 0 vs 0 (exactly)  — datosanimales.csv 2024 and 2025, cattle only
```

| name           |          opening |   births |   offtake |   rate |
|:---------------|-----------------:|---------:|----------:|-------:|
| Treinta y Tres | 613051           |   143895 |    160917 |  0.262 |
| Rocha          | 688313           |   181180 |    174553 |  0.254 |
| Cerro Largo    | 957379           |   231065 |    216665 |  0.226 |
| Paysandú       | 809798           |   188421 |    179667 |  0.222 |
| Maldonado      | 230533           |    55728 |     47540 |  0.206 |
| Lavalleja      | 644438           |   149071 |    127726 |  0.198 |
| Tacuarembó     |      1.03458e+06 |   244087 |    199859 |  0.193 |
| Río Negro      | 555782           |   112883 |    106598 |  0.192 |
| Artigas        | 730165           |   168821 |    138861 |  0.19  |
| Durazno        | 900286           |   223829 |    157244 |  0.175 |
| Salto          | 849010           |   213043 |    146395 |  0.172 |
| Flores         | 393191           |    91966 |     67201 |  0.171 |
| Colonia        | 551213           |    95197 |     92680 |  0.168 |
| Canelones      | 355686           |    51287 |     58553 |  0.165 |
| Rivera         | 608407           |   142668 |     96461 |  0.159 |
| Florida        |      1.09523e+06 |   220853 |    169868 |  0.155 |
| Soriano        | 570050           |    91871 |     81530 |  0.143 |
| San José       | 548346           |    91865 |     78096 |  0.142 |

### A correction to our own July report

The July 2026 expedition printed Rocha's extraction rate as 21.4%. Here it is
25.4%. The difference is the bug this notebook's introduction describes: the
July figure subtracted *every* species' deaths and home slaughter (sheep
included) from Rocha's cattle books. Done that way again, the arithmetic
reproduces the July number exactly, which is how we know that is what
happened.

```python
all_deaths = cw.num(a25["AnimalesPorEspecieMortandadPD_AD"]).groupby(a25["DepartamentoCodigo"]).sum()
all_cons = cw.num(a25["AnimalesPorEspecieConsumoPD_AD"]).groupby(a25["DepartamentoCodigo"]).sum()
r = dd.set_index("code").loc["14"]
contaminated = (r["births"] - all_deaths["14"] - all_cons["14"] - (r["closing"] - r["opening"])) / r["opening"]
cw.check("Rocha, species-honest (this notebook)", round(r["rate"], 3), 0.254, tol=0.0005, source="the table above")
cw.check("Rocha, with every species' deaths and home slaughter (the July bug)", round(contaminated, 3), 0.214,
         tol=0.0005, source="expedition 2026-07-05 printed 21.4%")
cw.finding("correction",
    f"The July 2026 expedition's figure for Rocha (21.4%) carried the all-species bug; species-honest, Rocha's "
    f"cattle offtake is **{r['rate']:.1%}** of its opening herd.")
```

```text
PASS  Rocha, species-honest (this notebook): 0.254 vs 0.254 (within ±0.0005)  — the table above
PASS  Rocha, with every species' deaths and home slaughter (the July bug): 0.214 vs 0.214 (within ±0.0005)  — expedition 2026-07-05 printed 21.4%
```

> **Finding.** The July 2026 expedition's figure for Rocha (21.4%) carried the all-species bug; species-honest, Rocha's cattle offtake is **25.4%** of its opening herd.

```python
top = dd.query("name != 'Montevideo'").iloc[0]
low = dd.query("name != 'Montevideo'").iloc[-1]
cw.finding("departments",
    f"By department the books run from **{top['name']}**, which shipped out {top['rate']:.1%} of its opening herd, "
    f"to {low['name']}, {low['rate']:.1%}. Montevideo (a few thousand head) is left out of the extremes.")
cc.show(cc.choropleth(dd, "rate", title="Inferred offtake as a share of opening stock, 2024 → 2025",
                      subtitle="cattle that left each department's farms (to slaughter, export, or another department)",
                      legend="offtake rate", fmt=".0%", domain=[0, 0.3]),
        "offtake-map", thumbnail=True,
        alt_text="Map of offtake rate by department; the breeding east ships the largest share of its herd.")
print(cc.map_credit())
```

> **Finding.** By department the books run from **Treinta y Tres**, which shipped out 26.2% of its opening herd, to San José, 14.2%. Montevideo (a few thousand head) is left out of the extremes.

![Map of offtake rate by department; the breeding east ships the largest share of its herd.](charts/offtake-map.svg)

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

## 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 cattle stock: cube = CSV                                       | 12,138,148.00 | 12,138,148.00 | exactly        | datosanimales.csv 2024, AnimalesPorEspeciePD_AD            |
| 2024 cattle births: cube = CSV                                      | 2,364,279.00  | 2,364,279.00  | exactly        | datosanimales.csv 2024, AnimalesPorEspecieNacimientosPD_AD |
| 2024 cattle deaths: cube = CSV                                      | 264,496.00    | 264,496.00    | exactly        | datosanimales.csv 2024, AnimalesPorEspecieMortandadPD_AD   |
| 2024 cattle consumption: cube = CSV                                 | 23,069.00     | 23,069.00     | exactly        | datosanimales.csv 2024, AnimalesPorEspecieConsumoPD_AD     |
| 2025 cattle stock: cube = CSV                                       | 12,265,269.00 | 12,265,269.00 | exactly        | datosanimales.csv 2025, AnimalesPorEspeciePD_AD            |
| 2025 cattle births: cube = CSV                                      | 2,697,988.00  | 2,697,988.00  | exactly        | datosanimales.csv 2025, AnimalesPorEspecieNacimientosPD_AD |
| 2025 cattle deaths: cube = CSV                                      | 247,498.00    | 247,498.00    | exactly        | datosanimales.csv 2025, AnimalesPorEspecieMortandadPD_AD   |
| 2025 cattle consumption: cube = CSV                                 | 22,826.00     | 22,826.00     | exactly        | datosanimales.csv 2025, AnimalesPorEspecieConsumoPD_AD     |
| offtake 2024 → 2025, head                                           | 2,300,543.00  | 2,300,543.00  | exactly        | cow-tools WP-0 oracle (e), 2026-07-06                      |
| extraction rate                                                     | 0.19          | 0.19          | within ±5e-05  | cow-tools WP-0 oracle (e): 18.95%                          |
| department offtakes sum to the national offtake                     | 2,300,543.00  | 2,300,543.00  | exactly        | same books, two groupings                                  |
| department offtakes: cube = CSV (largest difference, head)          | 0.00          | 0.00          | exactly        | datosanimales.csv 2024 and 2025, cattle only               |
| Rocha, species-honest (this notebook)                               | 0.25          | 0.25          | within ±0.0005 | the table above                                            |
| Rocha, with every species' deaths and home slaughter (the July bug) | 0.21          | 0.21          | within ±0.0005 | expedition 2026-07-05 printed 21.4%                        |

```text
     kind        target  rows                first line
0  sparql  uruguay-cows    38  SELECT ?year ?department
```

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