# One year on, 2024 to 2025

**Answer.** Between the 2024 and 2025 declarations Uruguay gained **127,121 cattle** (+1.05%) and lost **621,595 sheep** (-11.47%), while the number of holders declaring beef cattle went from 61,561 to 59,121 (-4.0%): more cattle, fewer hands.

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

## Findings

- Between the 2024 and 2025 declarations Uruguay gained **127,121 cattle** (+1.05%) and lost **621,595 sheep** (-11.47%), while the number of holders declaring beef cattle went from 61,561 to 59,121 (-4.0%): more cattle, fewer hands.
- Growth came from the north and centre (**Salto** +5.9%, Durazno +5.8%, Rivera +5.2%); the south and east shrank (**Canelones** -4.8%, Treinta y Tres -4.6%).

## The notebook

Two declarations, a year apart. Before comparing them we read what the cube
says about its own quality: cow-tools records every warning raised at ingest
as a `cow:QualityWarning` in the year's quality graph. A comparison that
leans on a dimension a warning touches must say so, or not be made.

```python
# Setup.
import re

import altair as alt
import pandas as pd

import cowtools_client as cw
import cowtools_charts as cc

names = cc.department_names()
```

## First, the warnings

```python
# The query: every quality report and warning the cube recorded, per year.
reports = cw.sparql("""
SELECT ?year ?passed ?blocking ?warnings
WHERE { GRAPH ?g { ?r a cow:QualityReport ; cow:sourceYear ?year ; cow:passed ?passed ;
                      cow:blockingViolationCount ?blocking ; cow:warningCount ?warnings } }
ORDER BY ?year
""")
warnings = cw.sparql("""
SELECT ?year ?message
WHERE { GRAPH ?g { ?w a cow:QualityWarning ; cow:sourceYear ?year ; cow:message ?message } }
ORDER BY ?year ?message
""", allow_empty=True)
cc.show_table(reports, name="quality-reports")
cc.show_table(warnings, name="quality-warnings")
```

| year   | passed   |   blocking |   warnings |
|:-------|:---------|-----------:|-----------:|
| 2,024  | True     |          0 |          1 |
| 2,025  | True     |          0 |          3 |

| year   | message                                                                                                                                                                                                                                 |
|:-------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 2,024  | optional source file not found: Codigos de areas de enumeracion                                                                                                                                                                         |
| 2,025  | codelist 'scheme_mgap_production_types' has no table for snapshot year 2025; borrowed 9 concept(s) from snapshot year 2024 (explicit cross-year borrow, recorded as <urn:sophia:cow:codelist-borrow/2025/scheme-mgap-production-types>) |
| 2,025  | optional source file not found: Codigos de areas de enumeracion                                                                                                                                                                         |
| 2,025  | optional source file not found: Codigos de tipos de produccion MGAP                                                                                                                                                                     |

```python
cw.check("both years passed their quality gate", bool(reports["passed"].all()), True, source="cow:QualityReport")
cw.check("no blocking violations in either year", int(reports["blocking"].sum()), 0, source="cow:QualityReport")
cw.check("every counted warning is recorded", int(reports["warnings"].sum()), len(warnings),
         source="cow:warningCount vs cow:QualityWarning rows")
```

```text
PASS  both years passed their quality gate: True vs True (exactly)  — cow:QualityReport
PASS  no blocking violations in either year: 0 vs 0 (exactly)  — cow:QualityReport
PASS  every counted warning is recorded: 4 vs 4 (exactly)  — cow:warningCount vs cow:QualityWarning rows
```

Each warning names what it touches: a codelist borrowed from another year,
or an optional source file missing from a package. This comparison uses
three things: the **department** and **species** codelists and the
**datosanimales** table. We collect what the warnings touch and refuse to go
on if any of the three is among them.

```python
touched = set()
for msg in warnings["message"]:
    touched |= set(re.findall(r"codelist '([^']+)'", msg))
    touched |= set(m.strip() for m in re.findall(r"optional source file not found: (.+)", msg))
print("touched by a warning:", sorted(touched) or "nothing")
uses = {"departments", "species", "datos_animales", "Datos animales"}
hit = sorted(t for t in touched if any(u.lower().replace("_", " ") in t.lower().replace("_", " ") for u in uses))
cw.check("no warning touches the dimensions this comparison uses", hit, [], source=f"uses: {sorted(uses)}")
```

```text
touched by a warning: ['Codigos de areas de enumeracion', 'Codigos de tipos de produccion MGAP', 'scheme_mgap_production_types']
PASS  no warning touches the dimensions this comparison uses: [] vs [] (exactly)  — uses: ['Datos animales', 'datos_animales', 'departments', 'species']
```

## Cattle, sheep and holders, both years

```python
# The query: stock and holders per department, species and year.
q = cw.sparql("""
SELECT ?year ?department ?species (SUM(?n) AS ?animals) (SUM(?h) AS ?holders)
WHERE {
  GRAPH ?g {
    ?obs a cow:AnimalFlowObservation ; cow:year ?year ; cow:department ?department ;
         cow:species ?species ; cow:animalsCount ?n ; cow:holdersCount ?h .
  }
}
GROUP BY ?year ?department ?species
""")
q["code"] = q["department"].map(cw.dept_code)
q["sp"] = q["species"].map(cw.dept_code)
q["kind"] = q["sp"].map({"1": "cattle", "11": "cattle", "2": "sheep"})
nat = q.dropna(subset=["kind"]).groupby(["kind", "year"])["animals"].sum().unstack()
beef_holders = q.query("sp == '1'").groupby("year")["holders"].sum()
nat
```

```text
year        2024      2025
kind                      
cattle  12138148  12265269
sheep    5420661   4799066
```

```python
for year in (2024, 2025):
    csv = cw.source_csv(year, "datosanimales.csv")
    beef = csv[csv["EspecieCodigo"] == "1"]
    cw.check(f"{year} holders declaring beef cattle: cube = CSV", int(beef_holders[year]),
             int(cw.num(beef["CantidadTenedoresPorEspecie"]).sum()), source=f"datosanimales.csv {year}, CantidadTenedoresPorEspecie")
change = nat[2025] / nat[2024] - 1
cw.check("cattle 2024 → 2025", round(change["cattle"], 4), 0.0105, tol=0.00005, source="expedition 2026-07-05: +1.05%")
cw.finding("headline",
    f"Between the 2024 and 2025 declarations Uruguay gained **{int(nat.loc['cattle', 2025] - nat.loc['cattle', 2024]):,} cattle** "
    f"({change['cattle']:+.2%}) and lost **{int(nat.loc['sheep', 2024] - nat.loc['sheep', 2025]):,} sheep** "
    f"({change['sheep']:+.2%}), while the number of holders declaring beef cattle went from "
    f"{int(beef_holders[2024]):,} to {int(beef_holders[2025]):,} ({beef_holders[2025]/beef_holders[2024]-1:+.1%}): "
    f"more cattle, fewer hands.")
cw.check("fewer holders declared beef cattle in 2025 (so 'fewer hands' holds)", bool(beef_holders[2025] < beef_holders[2024]),
         True, source="the two numbers above")
```

```text
PASS  2024 holders declaring beef cattle: cube = CSV: 61,561 vs 61,561 (exactly)  — datosanimales.csv 2024, CantidadTenedoresPorEspecie
PASS  2025 holders declaring beef cattle: cube = CSV: 59,121 vs 59,121 (exactly)  — datosanimales.csv 2025, CantidadTenedoresPorEspecie
PASS  cattle 2024 → 2025: 0.0105 vs 0.0105 (within ±5e-05)  — expedition 2026-07-05: +1.05%
```

> **Finding.** Between the 2024 and 2025 declarations Uruguay gained **127,121 cattle** (+1.05%) and lost **621,595 sheep** (-11.47%), while the number of holders declaring beef cattle went from 61,561 to 59,121 (-4.0%): more cattle, fewer hands.

```text
PASS  fewer holders declared beef cattle in 2025 (so 'fewer hands' holds): True vs True (exactly)  — the two numbers above
```

## Department by department

```python
cat = q.query("kind == 'cattle'").groupby(["code", "year"])["animals"].sum().unstack()
cat.columns = ["cattle_2024", "cattle_2025"]
cat["change"] = cat["cattle_2025"] / cat["cattle_2024"] - 1
cat = cat.reset_index()
cat["name"] = cat["code"].map(names)
cat = cat.sort_values("change", ascending=False)
c = cat.set_index("name")["change"]
for name, expected in [("Salto", 0.059), ("Durazno", 0.058), ("Rivera", 0.052), ("Canelones", -0.048),
                       ("Treinta y Tres", -0.046)]:
    cw.check(f"{name} cattle 2024 → 2025", round(c[name], 3), expected, tol=0.0005, source="expedition 2026-07-05")
cc.show_table(cat[["name", "cattle_2024", "cattle_2025", "change"]], floatfmt="+.3f", name="cattle-change")
```

```text
PASS  Salto cattle 2024 → 2025: 0.059 vs 0.059 (within ±0.0005)  — expedition 2026-07-05
PASS  Durazno cattle 2024 → 2025: 0.058 vs 0.058 (within ±0.0005)  — expedition 2026-07-05
PASS  Rivera cattle 2024 → 2025: 0.052 vs 0.052 (within ±0.0005)  — expedition 2026-07-05
PASS  Canelones cattle 2024 → 2025: -0.048 vs -0.048 (within ±0.0005)  — expedition 2026-07-05
PASS  Treinta y Tres cattle 2024 → 2025: -0.046 vs -0.046 (within ±0.0005)  — expedition 2026-07-05
```

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

```python
# The sentence below names regions: hold it to them.
NORTH_CENTRE = {"Artigas", "Salto", "Rivera", "Tacuarembó", "Paysandú", "Durazno", "Cerro Largo", "Flores"}
SOUTH_EAST = {"Canelones", "Montevideo", "Maldonado", "Rocha", "Treinta y Tres", "Lavalleja", "San José", "Colonia"}
cw.check("the three fastest growers are in the north and centre", set(cat["name"].head(3)) <= NORTH_CENTRE, True,
         source=str(sorted(NORTH_CENTRE)))
cw.check("the two steepest declines are in the south and east", set(cat["name"].tail(2)) <= SOUTH_EAST, True,
         source=str(sorted(SOUTH_EAST)))
cw.finding("where",
    f"Growth came from the north and centre (**{cat.iloc[0]['name']}** {cat.iloc[0]['change']:+.1%}, "
    f"{cat.iloc[1]['name']} {cat.iloc[1]['change']:+.1%}, {cat.iloc[2]['name']} {cat.iloc[2]['change']:+.1%}); "
    f"the south and east shrank (**{cat.iloc[-1]['name']}** {cat.iloc[-1]['change']:+.1%}, "
    f"{cat.iloc[-2]['name']} {cat.iloc[-2]['change']:+.1%}).")
```

```text
PASS  the three fastest growers are in the north and centre: True vs True (exactly)  — ['Artigas', 'Cerro Largo', 'Durazno', 'Flores', 'Paysandú', 'Rivera', 'Salto', 'Tacuarembó']
PASS  the two steepest declines are in the south and east: True vs True (exactly)  — ['Canelones', 'Colonia', 'Lavalleja', 'Maldonado', 'Montevideo', 'Rocha', 'San José', 'Treinta y Tres']
```

> **Finding.** Growth came from the north and centre (**Salto** +5.9%, Durazno +5.8%, Rivera +5.2%); the south and east shrank (**Canelones** -4.8%, Treinta y Tres -4.6%).

```python
cc.show(cc.choropleth(cat, "change", title="Change in cattle, 2024 → 2025",
                      subtitle="beef + dairy, PD_AD; blue is growth, brown is decline", legend="change",
                      fmt="+.1%", diverging=True),
        "cattle-change-map", thumbnail=True,
        alt_text="Diverging map: the north and centre (Salto, Durazno, Rivera) gained the most cattle; Canelones and Treinta y Tres lost the most.")
print(cc.map_credit())
```

![Diverging map: the north and centre (Salto, Durazno, Rivera) gained the most cattle; Canelones and Treinta y Tres lost the most.](charts/cattle-change-map.svg)

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

### The same change as a slope chart

Each line is a department, from its 2024 to its 2025 herd (log scale, so a
line's slope is its percentage change whatever the herd's size).

```python
slope = cat.melt(id_vars=["name", "change"], value_vars=["cattle_2024", "cattle_2025"], var_name="year",
                 value_name="cattle")
slope["year"] = slope["year"].str[-4:]
slope["direction"] = slope["change"].map(lambda v: "grew" if v > 0 else "shrank")
base = alt.Chart(slope).encode(
    x=alt.X("year:N", title=None, axis=alt.Axis(labelAngle=0)),
    y=alt.Y("cattle:Q", scale=alt.Scale(type="log"), title="cattle (log scale)", axis=alt.Axis(format="~s")),
    color=alt.Color("direction:N", scale=alt.Scale(domain=["grew", "shrank"], range=[cc.CATTLE, cc.FALL]),
                    title=None),
    detail="name:N",
    tooltip=["name:N", alt.Tooltip("cattle:Q", format=","), alt.Tooltip("change:Q", format="+.1%")],
)
lines = base.mark_line(point=True)
lab = alt.Chart(slope).transform_filter("datum.year == '2025'").mark_text(align="left", dx=6, fontSize=10).encode(
    x=alt.X("year:N"), y=alt.Y("cattle:Q", scale=alt.Scale(type="log")), text="name:N")
cc.show((lines + lab).properties(width=260, height=440, title=alt.Title("Cattle by department, 2024 → 2025")),
        "cattle-slope",
        alt_text="Slope chart: most department lines rise gently; Canelones and Treinta y Tres fall.")
```

![Slope chart: most department lines rise gently; Canelones and Treinta y Tres fall.](charts/cattle-slope.svg)

Chart data and encoding: [charts/cattle-slope.vl.json](charts/cattle-slope.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                                                                                                  |
|:--------------------------------------------------------------------|:-------|:-----------|:---------------|:--------------------------------------------------------------------------------------------------------|
| both years passed their quality gate                                | True   | True       | exactly        | cow:QualityReport                                                                                       |
| no blocking violations in either year                               | 0      | 0          | exactly        | cow:QualityReport                                                                                       |
| every counted warning is recorded                                   | 4      | 4          | exactly        | cow:warningCount vs cow:QualityWarning rows                                                             |
| no warning touches the dimensions this comparison uses              | []     | []         | exactly        | uses: ['Datos animales', 'datos_animales', 'departments', 'species']                                    |
| 2024 holders declaring beef cattle: cube = CSV                      | 61561  | 61561      | exactly        | datosanimales.csv 2024, CantidadTenedoresPorEspecie                                                     |
| 2025 holders declaring beef cattle: cube = CSV                      | 59121  | 59121      | exactly        | datosanimales.csv 2025, CantidadTenedoresPorEspecie                                                     |
| cattle 2024 → 2025                                                  | 0.0105 | 0.0105     | within ±5e-05  | expedition 2026-07-05: +1.05%                                                                           |
| fewer holders declared beef cattle in 2025 (so 'fewer hands' holds) | True   | True       | exactly        | the two numbers above                                                                                   |
| Salto cattle 2024 → 2025                                            | 0.059  | 0.059      | within ±0.0005 | expedition 2026-07-05                                                                                   |
| Durazno cattle 2024 → 2025                                          | 0.058  | 0.058      | within ±0.0005 | expedition 2026-07-05                                                                                   |
| Rivera cattle 2024 → 2025                                           | 0.052  | 0.052      | within ±0.0005 | expedition 2026-07-05                                                                                   |
| Canelones cattle 2024 → 2025                                        | -0.048 | -0.048     | within ±0.0005 | expedition 2026-07-05                                                                                   |
| Treinta y Tres cattle 2024 → 2025                                   | -0.046 | -0.046     | within ±0.0005 | expedition 2026-07-05                                                                                   |
| the three fastest growers are in the north and centre               | True   | True       | exactly        | ['Artigas', 'Cerro Largo', 'Durazno', 'Flores', 'Paysandú', 'Rivera', 'Salto', 'Tacuarembó']            |
| the two steepest declines are in the south and east                 | True   | True       | exactly        | ['Canelones', 'Colonia', 'Lavalleja', 'Maldonado', 'Montevideo', 'Rocha', 'San José', 'Treinta y Tres'] |

```text
     kind        target  rows  \
0  sparql  uruguay-cows     2   
1  sparql  uruguay-cows     4   
2  sparql  uruguay-cows   228   

                                          first line  
0           SELECT ?year ?passed ?blocking ?warnings  
1                              SELECT ?year ?message  
2  SELECT ?year ?department ?species (SUM(?n) AS ...
```

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