Dairy milk procurement: fat, SNF and price per litre by centre
In this article (8 sections)
Related programme: the Data Analytics with Generative AI course (3–4 months; ₹90,000 (inclusive of GST) · EMI from ₹4,000/month).
To compare milk collection centres, weight fat and SNF by the quantity of milk poured, not by the number of farmers, and compare procurement cost per kilogram of milk solids as well as per litre. Price per litre mostly reflects how much buffalo milk a centre collects; price per kg of fat plus SNF shows whether the centre is paying more for the same solids. In the synthetic shift below, one centre pays ₹57.85 a litre and the other ₹41.08, but the gap per kg of solids is far smaller.
A procurement head's actual worry
A dairy's procurement manager sees a report that says Centre A's milk costs ₹16 a litre more than Centre B's. The instinct is to suspect the rate chart, the testing machine or the centre operator. Sometimes that is right. More often the centres simply buy different milk: buffalo milk carries more fat and solids and is priced higher under almost any fat-and-SNF based chart. The analytical job is to separate mix from price, and only then look for testing or payment anomalies.
Pouring records and what each field means
A typical automatic milk collection unit, or the software that consolidates it, gives one row per farmer per shift:
| Column | Meaning | Caution |
|---|---|---|
| centre, route | Village collection point and the route that tankers it | Centres change route; keep the mapping by date |
| farmer_id, shift | Member code, morning or evening | Evening milk often tests differently |
| milk_type | cow, buffalo, mixed | Sometimes inferred from fat level instead of declared |
| litres or kg | Quantity measured at the centre | Know which one your unit records |
| fat_pct, snf_pct | Test results from the analyser | Default values when the analyser was down |
| rate, amount_paid | From the price chart in force for that date | Chart versions change during the year |
The price chart itself is set by each dairy or cooperative and varies across organisations and seasons. Treat it as an input table with an effective date; do not hardcode it in your analysis.
From percentages to kilograms
Fat and SNF percentages only aggregate correctly as kilograms:
- kg milk = litres × density (use the conversion your plant applies; the code treats it as a parameter)
- kg fat = kg milk × fat% ÷ 100, and likewise kg SNF
- Weighted fat % for a centre = Σ kg fat ÷ Σ kg milk × 100
- Cost per litre = Σ amount paid ÷ Σ litres
- Cost per kg solids = Σ amount paid ÷ (Σ kg fat + Σ kg SNF)
The simple mean of farmers' fat percentages is the most common error in centre reports. It gives a farmer pouring four litres the same weight as one pouring forty.
Synthetic example: two centres, one morning
import pandas as pd
# Synthetic milk pouring records from two collection centres, one morning shift
pour = pd.DataFrame({
"centre": ["Khurja"] * 4 + ["Sikandrabad"] * 4,
"farmer_id": ["F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8"],
"milk_type": ["buffalo", "cow", "cow", "buffalo", "cow", "cow", "cow", "buffalo"],
"litres": [40.0, 8.0, 6.0, 12.0, 30.0, 25.0, 5.0, 4.0],
"fat_pct": [6.8, 4.1, 3.9, 7.0, 3.6, 3.8, 4.4, 6.5],
"snf_pct": [9.0, 8.4, 8.2, 9.1, 8.1, 8.3, 8.6, 9.0],
"amount_paid": [2480.0, 336.0, 246.0, 756.0, 1170.0, 1000.0, 215.0, 244.0],
})
DENSITY_KG_PER_L = 1.03 # assumed conversion; replace with your plant's figure
pour["kg_milk"] = pour["litres"] * DENSITY_KG_PER_L
pour["kg_fat"] = pour["kg_milk"] * pour["fat_pct"] / 100
pour["kg_snf"] = pour["kg_milk"] * pour["snf_pct"] / 100
centre = pour.groupby("centre").agg(
litres=("litres", "sum"), kg_milk=("kg_milk", "sum"),
kg_fat=("kg_fat", "sum"), kg_snf=("kg_snf", "sum"),
amount=("amount_paid", "sum"), simple_avg_fat=("fat_pct", "mean"))
centre["weighted_fat_pct"] = 100 * centre["kg_fat"] / centre["kg_milk"]
centre["weighted_snf_pct"] = 100 * centre["kg_snf"] / centre["kg_milk"]
centre["rs_per_litre"] = centre["amount"] / centre["litres"]
centre["rs_per_kg_solids"] = centre["amount"] / (centre["kg_fat"] + centre["kg_snf"])
print(centre[["litres", "simple_avg_fat", "weighted_fat_pct", "weighted_snf_pct",
"rs_per_litre", "rs_per_kg_solids"]].round(2).to_string())
by_type = pour.groupby(["centre", "milk_type"])["litres"].sum().unstack(fill_value=0)
print((100 * by_type.div(by_type.sum(axis=1), axis=0)).round(0).to_string())Output:
litres simple_avg_fat weighted_fat_pct weighted_snf_pct rs_per_litre rs_per_kg_solids
centre
Khurja 66.0 5.45 6.25 8.87 57.85 371.50
Sikandrabad 64.0 4.58 3.92 8.27 41.08 327.02
milk_type buffalo cow
centre
Khurja 79.0 21.0
Sikandrabad 6.0 94.0Reading the result like a procurement manager
Khurja's milk is 79% buffalo by volume against Sikandrabad's 6%. That alone explains most of the ₹16.77 per-litre gap. Per kg of solids, the gap narrows to about ₹44, and that residual is the part worth investigating: is Khurja's chart more generous, are some pours priced above the chart, or does the mix of fat and SNF within those solids differ?
Notice also that the simple average understates Khurja's fat (5.45% against a weighted 6.25%) because its biggest pourer has high-fat milk, and overstates Sikandrabad's (4.58% against 3.92%) because its small pourers test higher. A report built on simple means misstates both centres, in opposite directions, and nobody notices because the numbers look plausible.
Checks before you blame the centre
- 1Reprice every pour from the chart. Join each row to the chart version in force on its date and compare the expected amount with
amount_paid. Differences point to manual overrides or outdated charts on the unit. - 2Look for default test values. A run of identical fat and SNF readings for many farmers usually means the analyser was bypassed. Flag shifts where the share of repeated values is unusually high.
- 3Reconcile with the chilling centre or plant. Solids bought at village centres should roughly match solids received at the plant after the tanker's own test. A persistent shortfall on one route is a handling or adulteration question, and it needs the plant's lab data, not just centre records.
- 4Separate morning and evening shifts. Their composition often differs; mixing them in one average hides shift-specific problems.
- 5Keep seasons apart. Flush and lean seasons shift both volumes and composition. Compare a centre with its own same month last year before comparing it with a neighbour.
A one-page brief
For the procurement head, one table per route is enough: litres, buffalo share, weighted fat and SNF, cost per litre, cost per kg solids, and the chart-repricing difference in rupees. Add a single sentence at the top that states the conclusion in business terms, for example: "Most of the per-litre gap between the two centres is milk mix; about ₹44 per kg of solids remains unexplained and the repricing check is the next step."
Resist the urge to rank farmers. Their data belongs to a member-owned relationship in many dairies, and the decision the manager needs is about centres, charts and testing equipment.
Related practice
Weighted averages that go wrong when you aggregate percentages show up across operations analytics, including warehouse pick rates. Aggregation discipline in pandas is a core part of the Data Analyst programme. If you are deciding between an analytics and a modelling path, data analytics vs data science sets out the difference.
Pankit Kumar has 10 years in Data Science & AI, building and shipping production systems in regulated pharma and clinical environments. He is a freelance trainer at Boston Institute of Analytics, AnalytixLabs and Scaler, and has taught this material to thousands of working professionals.
This article is part of our Data Analytics with Generative AI programme — 3–4 months. The full analyst stack — Excel, SQL, Power BI and Python pipelines — then a generative-AI layer you can prove is right.
Explore Data Analytics with Generative AI