feat(simulation): a real crop calendar for the simulated price peaks

Replaces a hash of the commodity code with a table of the week each commodity's
price actually peaks. The peak is the lean season, not the harvest: wheat comes
in during March and April and is cheapest then, and dearest in November when
last season's stock has run down and the new crop is still in the ground. Wheat
is therefore week 48, not week 14, and the same inversion runs down the list —
rabi peaks in autumn, kharif in early summer, and winter vegetables peak through
the monsoon when they cannot be grown at all.

This is not cosmetic. margin_squeeze reads a sector's OUTPUT price against its
INPUT price, and a squeeze is only real when the two peak at different times of
year. A hashed peak scattered them arbitrarily, 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 precisely what the derivation measures, so it has to come from a
calendar rather than from a hash.

Approximate and national, and both caveats matter: harvest windows shift by
weeks across fourteen districts and six zones, and this carries one figure each.
Better than a hash, not a district crop calendar. A commodity with no entry logs
a warning and falls back to the hash rather than failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
LincPay
2026-08-15 16:17:14 +05:30
co-authored by Claude Opus 5
parent 81e66a9b57
commit bbf226506b
@@ -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 <em>peaks</em> — which is not when it is
* harvested, and getting that backwards inverts every series.
*
* <p>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.
*
* <p>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.
*
* <p>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<String, Integer> 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 {
* <p>An explicit hand-anchored profile wins; otherwise the level comes from
* {@link #CROP_BASE} and the shape from the commodity's group.
*
* <p>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 <em>the</em> 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.
* <p>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());
}