How we standardized SNIIM wholesale prices
We turn 47 SNIIM reports (25 distinct source schemas) into a single canonical model: one fact table, shared dimensions and explicit unit and data-quality rules.
Generated from
sniim-extractor/src/core/canonical.py— do not edit by hand. Regenerate withsniim-extractor/.venv/bin/python scripts/build_standardization_note.py.
Why standardize
SNIIM publishes 47 independent reports: daily bulletins per wholesale market for agricultural, livestock and fisheries products. Each report ships as its own table, with column names, units and assumptions that change between sectors and sometimes within a sector.
Unstandardized, those tables are not an analysis surface: the price arrives in a column called Precio Mín in one bulletin, Pmín in another and Pechuga_Mín($/kg) in a third; the same column mixes prices per caja, manojo, piece or kilogram; and 0.00 means "not reported", not "free".
The canonical model resolves those differences once, in the pipeline, so one query works across all three sectors and every price can be compared per kilogram.
Before and after
Four real reports, each in its source shape, and the rows the pipeline produces. Values are illustrative; the transformation is the same one scripts/build_catalog.py runs on the full data.
frutas_y_hortalizas — Agricultural
| Bulletin column | Value |
|---|---|
Fecha |
2025-06-18 |
Producto |
Aguacate |
Calidad |
Primera |
Presentación |
Caja de 10 kg |
Origen |
Michoacán |
Precio Mín |
450.00 |
Precio Max |
520.00 |
Precio Frec |
480.00 |
Obs. |
-- |
The same observation in prices (one row per price component):
| product_raw | variant | presentation_raw | unit | price_kind | price_mxn | price_per_kg |
|---|---|---|---|---|---|---|
| Aguacate | Primera | Caja de 10 kg | caja |
min |
450.00 | 45.00 |
| Aguacate | Primera | Caja de 10 kg | caja |
max |
520.00 | 52.00 |
| Aguacate | Primera | Caja de 10 kg | caja |
mode |
480.00 | 48.00 |
destino_pescados_marinos — Fisheries
| Bulletin column | Value |
|---|---|
Fecha |
2025-06-18 |
Producto |
Huachinango |
Presentación |
Kilogramo |
Origen |
Veracruz |
Pmín |
180.00 |
Pmáx |
220.00 |
Pfrec |
200.00 |
Obs. |
-- |
The same observation in prices (one row per price component):
| product_raw | variant | presentation_raw | unit | price_kind | price_mxn | price_per_kg |
|---|---|---|---|---|---|---|
| Huachinango | — | Kilogramo | kg |
min |
180.00 | 180.00 |
| Huachinango | — | Kilogramo | kg |
max |
220.00 | 220.00 |
| Huachinango | — | Kilogramo | kg |
mode |
200.00 | 200.00 |
aves_pollo_pza — Livestock
| Bulletin column | Value |
|---|---|
Fecha |
2025-06-18 |
Destino |
Todos |
Pechuga_Mín($/kg) |
78.00 |
Pechuga_Máx($/kg) |
92.00 |
Pechuga_Frec($/kg) |
85.00 |
PiernaMuslo_Mín($/kg) |
42.00 |
PiernaMuslo_Máx($/kg) |
55.00 |
PiernaMuslo_Frec($/kg) |
48.00 |
The same observation in prices (one row per price component):
| product_raw | variant | presentation_raw | unit | price_kind | price_mxn | price_per_kg |
|---|---|---|---|---|---|---|
| Pechuga de pollo | — | Kilogramo | kg |
min |
78.00 | 78.00 |
| Pechuga de pollo | — | Kilogramo | kg |
max |
92.00 | 92.00 |
| Pechuga de pollo | — | Kilogramo | kg |
mode |
85.00 | 85.00 |
| Pierna y muslo de pollo | — | Kilogramo | kg |
min |
42.00 | 42.00 |
| Pierna y muslo de pollo | — | Kilogramo | kg |
max |
55.00 | 55.00 |
| Pierna y muslo de pollo | — | Kilogramo | kg |
mode |
48.00 | 48.00 |
azucar — Agricultural
| Bulletin column | Value |
|---|---|
Fecha |
2025-06-18 |
Tipo Producto |
Azúcar estándar |
Ingenio |
Ing. Pánuco |
Mercado |
Todos |
destino |
Nacional |
Precio encuestado |
1150.00 |
The same observation in prices (one row per price component):
| product_raw | variant | presentation_raw | unit | price_kind | price_mxn | price_per_kg |
|---|---|---|---|---|---|---|
| Azúcar estándar | Ing. Pánuco | Bulto de 50 kg | bulto |
single |
1150.00 | 23.00 |
Column mapping
Table generated from the core.canonical.ENDPOINTS registry. Every source column lands in a canonical field or in a measure with its price_kind. standardization-note.json carries the column-by-column mapping for all 47 tables.
Agricultural (5 endpoints)
| Endpoint | Product | Presentation | Market | Origin | Variant / attributes | Measures → price_kind |
|---|---|---|---|---|---|---|
aceites_comestibles |
Variedad |
Presentación |
destino |
— |
Producto |
Precio Mín → min · Precio Max → max · Precio Frec → mode |
azucar |
Tipo Producto |
Bulto de 50 kg | Mercado |
— |
Ingenio, destino_label ← destino |
Precio encuestado → single |
flores |
Producto |
Presentación |
destino |
Origen |
— | Precio Mín → min · Precio Max → max · Precio Frec → mode |
frutas_y_hortalizas |
Producto |
Presentación |
destino |
Origen |
Calidad |
Precio Mín → min · Precio Max → max · Precio Frec → mode |
granos_basicos |
Producto |
Kilogramo | destino |
Origen |
— | Precio Mín → min · Precio Max → max · Precio Frec → mode |
Livestock (22 endpoints)
| Endpoint | Product | Presentation | Market | Origin | Variant / attributes | Measures → price_kind |
|---|---|---|---|---|---|---|
aves_huevo |
Producto |
Presentación |
Destino |
— |
destino_1_label ← destino_1 |
Preciomínimo($/kg) → min · Preciomáximo($/kg) → max · Preciofrecuente($/kg) → mode |
aves_pollo_entero |
Producto |
Kilogramo | Destino |
Origen |
Marca, peso_en_pie ← Pesoen pie(kg), peso_en_canal ← Pesoen canal(kg), destino_1_label ← destino_1 |
Precio($/kg) → single |
aves_pollo_pza |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
12 columns: Pechuga de pollo, Pierna y muslo de pollo, Retazo de pollo, Vísceras de pollo × min/max/mode |
becerros_carne_canal |
— |
Kilogramo | Destino |
— |
peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
2 columns: Canal de becerro × min/max |
becerros_ganado_pie |
— |
Kilogramo | Destino |
Origen |
num_cabezas ← No.decabezas, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
2 columns: Becerro en pie × min/max |
becerros_subproductos |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
4 columns: Vísceras de becerro, Piel y sangre de becerro × min/max |
bovinos_carne_canal |
— |
Kilogramo | Destino |
— |
Tipo, peso_promedio ← Pesopromedio(kg), num_canales ← Núm.canalesdesacrificio, destino_1_label ← destino_1 |
2 columns: Canal de bovino × min/max |
bovinos_cortes |
Corte |
Kilogramo | Destino |
Origen |
destino_1_label ← destino_1 |
Preciomínimo($/kg) → min · Preciomáximo($/kg) → max |
bovinos_empacadoras |
— |
Kilogramo | Destino |
Origen |
num_canales ← No.decanales, destino_1_label ← destino_1 |
1 column: Canal de bovino × single |
bovinos_ganado_pie |
— |
Kilogramo | Destino |
Origen |
num_cabezas ← No.decabezas, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
4 columns: Novillo en pie, Novillona en pie, Vaca en pie, Toro en pie × single |
bovinos_precios_origen |
— |
Kilogramo | Destino |
Origen |
destino_1_label ← destino_1 |
5 columns: Novillo de corral, Novillo de campo, Vaca vaquilla, Vaca desecho, Toro × single |
bovinos_subproductos |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
4 columns: Vísceras de bovino, Piel y sangre de bovino × min/max |
caprinos_carne_canal |
— |
Kilogramo | Destino |
— |
Tipo, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
1 column: Canal caprino × single |
caprinos_ganado_pie |
— |
Kilogramo | Destino |
Origen |
num_cabezas ← No.decabezas, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
2 columns: Caprino en pie × min/max |
caprinos_subproductos |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
4 columns: Vísceras caprinas, Piel y sangre caprina × min/max |
ovinos_carne_canal |
— |
Kilogramo | Destino |
— |
Tipo, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
1 column: Canal ovino × single |
ovinos_ganado_pie |
— |
Kilogramo | Destino |
Origen |
num_cabezas ← No.decabezas, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
2 columns: Ovino en pie × min/max |
ovinos_subproductos |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
4 columns: Vísceras ovinas, Piel y sangre ovina × min/max |
porcinos_carne_canal |
— |
Kilogramo | Destino |
— |
Tipo, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
4 columns: Canal porcino, Capote porcino × min/max |
porcinos_ganado_pie |
— |
Kilogramo | Destino |
Origen |
Tipo, num_cabezas ← No.decabezas, peso_promedio ← Pesopromedio(kg), destino_1_label ← destino_1 |
2 columns: Porcino en pie × min/max |
porcinos_obradores |
Producto |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
Preciopor Kilogramo($/kg) → single |
porcinos_subproductos |
— |
Kilogramo | Destino |
— |
destino_1_label ← destino_1 |
4 columns: Vísceras porcinas, Grasa porcina × min/max |
Fisheries (20 endpoints)
| Endpoint | Product | Presentation | Market | Origin | Variant / attributes | Measures → price_kind |
|---|---|---|---|---|---|---|
destino_agua_dulce |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
destino_crustaceos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
destino_filetes |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
destino_moluscos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
destino_pescados_marinos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
menudeo_agua_dulce |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
menudeo_crustaceos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
menudeo_filetes |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
menudeo_moluscos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
menudeo_pescados_marinos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
origen_agua_dulce |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
origen_crustaceos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
origen_filetes |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
origen_moluscos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
origen_pescados_marinos |
Producto |
Kilogramo | Todos | Origen |
— | Pmín → min · Pmáx → max · Pfrec → mode |
viga_agua_dulce |
Producto |
Kilogramo | Todos | — |
— | Pmín → min · Pmáx → max · Pfrec → mode |
viga_crustaceos |
Producto |
Kilogramo | Todos | — |
— | Pmín → min · Pmáx → max · Pfrec → mode |
viga_moluscos |
Producto |
Kilogramo | Todos | — |
— | Pmín → min · Pmáx → max · Pfrec → mode |
viga_pescados_marinos |
Producto |
Kilogramo | Todos | — |
— | Pmín → min · Pmáx → max · Pfrec → mode |
enlatados |
Producto |
Presentación |
Todos | — |
Marca, Tipocomercial |
Precioúnico → single |
Unit and price rules
Presentation → unit → price per kilo
parse_presentation maps the published presentation to a unit (kg, caja, manojo, docena, litro, …). kg_per_unit is only filled when the bulletin states a weight — Caja de 10 kg, Caja con 48 latas de 425 gr — or when the presentation is the kilogram. With a weight, price_per_kg = price_mxn / kg_per_unit; without one, price_per_kg is NULL and presentations.conversion_note says why.
Market → state, name and kind
parse_market splits the state prefix (Q.R., N.L., Michoacán) from the market or plant name and classifies the kind (mercado, rastro_planta, obrador, zona_metropolitana, nacional). The raw label is kept in market_aliases, so every row can be traced back to the exact bulletin text. The catalogue resolves 1,060 labels into 227 canonical markets.
Data quality
Rules are applied at build time and every affected value is recorded in dq_issues; nothing is corrected silently.
| Rule | Action | What it does |
|---|---|---|
negative_price |
nulled |
Any price < 0 becomes NULL; the source row is kept in observations. |
zero_as_missing |
nulled |
0.00 means "not reported" upstream: the price becomes NULL. When every price on the row is 0 the row contributes no price facts. |
inverted_min_max |
swapped |
When min > max the two values are swapped (upstream entry inversion); every swap is recorded with the original pair. |
frec_out_of_range |
flagged |
The frequent price falls outside [min, max]; the value is kept and flagged because the source occasionally publishes a mode outside the range. |
Compatibility views
The 47 original table names remain available as views rebuilt from observations + prices, with the same columns in the same order as the bulletin. scripts/verify_catalog.py --roundtrip compares every non-price column row by row: if a view drifts from the source, the build fails.
The result in numbers
Counts from the endpoint registry and the last built catalog (docs/catalog-stats.json, 2026-11-05):
| Metric | Value |
|---|---|
| Source reports | 47 |
| Distinct source schemas | 25 |
| Canonical model tables | 10 |
| Price fact rows | 45,036,608 |
| Observations (bulletin rows) | 17,030,096 |
| Prices convertible to MXN/kg | 90.5% |
| Canonical products | 609 |
| Distinct presentations | 203 |
| Canonical markets | 227 |
| Resolved market labels | 1,060 |
Limits
The model does not invent weights: 90.5% of price rows convert to MXN/kg; the rest are piece, bunch, dozen or litre presentations where the bulletin publishes no weight. Those rows are kept with price_per_kg NULL and a conversion note.
This note describes the pipeline, not the quality of the upstream data: coverage gaps and SNIIM corrections are documented separately in _manifest.json and the data_gaps ledger.