diff --git a/src/main/java/com/lincfusion/udyamlinc/ingestion/NoFeedPriceSimulationService.java b/src/main/java/com/lincfusion/udyamlinc/ingestion/NoFeedPriceSimulationService.java index 7785546..f7a14c2 100644 --- a/src/main/java/com/lincfusion/udyamlinc/ingestion/NoFeedPriceSimulationService.java +++ b/src/main/java/com/lincfusion/udyamlinc/ingestion/NoFeedPriceSimulationService.java @@ -197,6 +197,64 @@ public class NoFeedPriceSimulationService { private static final GroupShape DEFAULT_SHAPE = new GroupShape(0.12, 0.020, 0.045); + /** + * The ISO week each commodity's price peaks — which is not when it is + * harvested, and getting that backwards inverts every series. + * + *

A mandi price troughs at the arrival glut and peaks in the lean season + * before the next one. Wheat is harvested in March–April and is cheapest then; + * it is dearest in November–December, when last season's stock has run down and + * the new crop is still in the ground. So the entry for wheat is week 48, not + * week 14. The same inversion applies right down the list: rabi crops peak in + * autumn, kharif crops peak in early summer, and winter vegetables peak in the + * monsoon when they cannot be grown. + * + *

Why this matters beyond looking right. {@code margin_squeeze} reads a + * sector's OUTPUT price against its INPUT price, and a squeeze is real when the + * two peak at different times of year. Deriving the peak from a hash of the + * commodity code — which is what this replaces — put those peaks in arbitrary + * places, so a poultry unit could show feed and egg peaking in the same week and + * a dairy could show them six months apart, both by accident. The seasonal + * relationship between an input and an output is the thing the derivation exists + * to measure, so it has to come from a calendar rather than from a hash. + * + *

Approximate and national, and both caveats are real. Harvest windows shift + * by weeks across fourteen districts spanning six zones — Bihar's paddy and + * Karnataka's are not the same week — and this table carries one figure for + * each. It is a better approximation than a hash, not a crop calendar for a + * specific district. Anything reading these rows sees {@code SIMULATED}. + */ + private static final Map PRICE_PEAK_WEEK = Map.ofEntries( + // Rabi cereals and pulses: harvested Mar–Apr, dearest in the autumn lean. + Map.entry("WHEAT", 48), Map.entry("GRAM", 42), Map.entry("MASOOR", 42), + Map.entry("MUSTARD", 42), + // Kharif: harvested Sep–Nov, dearest before the monsoon crop lands. + Map.entry("PADDY", 26), Map.entry("RICE", 26), Map.entry("MAIZE", 28), + Map.entry("SOYA", 30), Map.entry("BAJRA", 24), Map.entry("JOWAR", 32), + Map.entry("ARHAR", 36), Map.entry("MOONG", 12), Map.entry("COTTON", 30), + // Onion and potato are stored, so their peak is the far end of storage — + // Sep–Nov for onion, before the kharif crop, and autumn for potato. + Map.entry("ONION", 42), Map.entry("POTATO", 40), + // Winter vegetables: grown Nov–Feb, so dearest through the monsoon when + // the crop cannot be raised at all. + Map.entry("CAULIFLOWER", 24), Map.entry("CABBAGE", 24), Map.entry("GREEN_PEAS", 27), + Map.entry("CARROT", 30), Map.entry("BEETROOT", 28), + // Summer vegetables: the mirror image, dearest in the winter gap. + Map.entry("OKRA", 52), Map.entry("BITTER_GOURD", 50), Map.entry("BOTTLE_GOURD", 52), + Map.entry("CUCUMBER", 1), Map.entry("PUMPKIN", 10), + // Tomato peaks in the monsoon, when transit losses are worst — the + // annual spike that reaches the newspapers. + Map.entry("TOMATO", 27), Map.entry("BRINJAL", 28), Map.entry("GREEN_CHILLI", 32), + Map.entry("CAPSICUM", 28), + // Fruit peaks at the start of its own short season, then falls as + // arrivals build. + Map.entry("MANGO", 14), Map.entry("LITCHI", 20), Map.entry("GRAPES", 2), + Map.entry("BANANA", 20), Map.entry("PAPAYA", 18), Map.entry("POMEGRANATE", 30), + Map.entry("GUAVA", 30), + // Spices are stored and traded year-round, so the peak sits well after + // the harvest rather than opposite it. + Map.entry("GARLIC", 48), Map.entry("GINGER", 35), Map.entry("TURMERIC", 40)); + private final CommodityRepository commodityRepository; private final DistrictRepository districtRepository; private final DistrictCommodityRepository districtCommodityRepository; @@ -261,13 +319,11 @@ public class NoFeedPriceSimulationService { *

An explicit hand-anchored profile wins; otherwise the level comes from * {@link #CROP_BASE} and the shape from the commodity's group. * - *

The peak week is derived from the commodity code rather than from a crop - * calendar. That is a real limitation and worth naming: the series will have a - * consistent, deterministic season, but not necessarily the season — - * onion's simulated peak is not guaranteed to fall in the actual rabi arrival - * window. It matters for realism rather than for correctness, since every - * derivation reads the series against itself. Replacing this with a sowing and - * harvest calendar is the obvious improvement once one is on hand. + *

The peak week comes from {@link #PRICE_PEAK_WEEK}, the lean-season table. + * A commodity missing from it falls back to a hash of its code, which keeps the + * series deterministic but puts its season somewhere arbitrary — acceptable for + * a commodity nothing reads as a sector's input or output, and a warning that a + * calendar entry is missing if it ever is. */ private Profile profileFor(Commodity commodity) { Profile explicit = PROFILES.get(commodity.getCode()); @@ -276,7 +332,13 @@ public class NoFeedPriceSimulationService { } GroupShape shape = GROUP_SHAPES.getOrDefault(commodity.getCommodityGroup(), DEFAULT_SHAPE); double base = CROP_BASE.getOrDefault(commodity.getCode(), 3000.0); - int peakWeek = Math.floorMod(commodity.getCode().hashCode(), 52) + 1; + Integer calendarPeak = PRICE_PEAK_WEEK.get(commodity.getCode()); + if (calendarPeak == null) { + log.warn("No price-peak week for commodity {} — falling back to a hashed season", commodity.getCode()); + } + int peakWeek = calendarPeak != null + ? calendarPeak + : Math.floorMod(commodity.getCode().hashCode(), 52) + 1; return new Profile(base, shape.seasonalAmplitude(), shape.trendPerYear(), peakWeek, shape.weeklyNoise()); }