[{"data":1,"prerenderedAt":1930},["ShallowReactive",2],{"doc:\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Ffill-gaps-in-per-item-time-series-with-a-staleness-limit":3,"surround:\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Ffill-gaps-in-per-item-time-series-with-a-staleness-limit":1900,"ancestors:\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Ffill-gaps-in-per-item-time-series-with-a-staleness-limit":1909,"children:\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Ffill-gaps-in-per-item-time-series-with-a-staleness-limit":1914,"related:\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Ffill-gaps-in-per-item-time-series-with-a-staleness-limit":1915},{"id":4,"title":5,"body":6,"dateModified":1865,"datePublished":1865,"description":1866,"extension":1867,"faq":1868,"keyword":1877,"kind":1878,"meta":1879,"navigation":94,"path":1889,"seo":1890,"slug":1893,"stem":1894,"topics":1895,"type":1898,"__hash__":1899},"docs\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Ffill-gaps-in-per-item-time-series-with-a-staleness-limit\u002Findex.md","Fill Gaps in Per-Item Time Series with a Staleness Limit",{"type":7,"value":8,"toc":1853},"minimark",[9,19,24,53,59,63,66,229,236,240,243,415,433,437,444,662,682,814,898,902,921,1058,1064,1237,1241,1244,1725,1728,1732,1770,1774,1784,1788,1798,1804,1810,1816,1820,1849],[10,11,12,13,18],"p",{},"Supplier prices, exchange rates, stock levels and survey readings often arrive sparsely: a row is recorded only when something changes or when someone checks. For a monthly report you need a value for every item in every month, and the natural assumption is that the last recorded value still holds — so you carry it forward. Done naively, this breaks in three ways. A plain forward fill leaks one item's value into the next item's rows; a value recorded in January is still being reported as \"current\" in September; and an item that first appears in May gets back-filled or left mysteriously blank before it. This page builds the fill properly with pandas: a full item-by-period grid, a forward fill within each item, a cap on how far a value may be carried, the age of every value, and an Excel matrix that marks stale figures. It belongs to ",[14,15,17],"a",{"href":16},"\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002F","Handling Missing Data in Excel Reports",".",[20,21,23],"h2",{"id":22},"prerequisites","Prerequisites",[25,26,31],"pre",{"className":27,"code":28,"language":29,"meta":30,"style":30},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install pandas openpyxl\n","bash","",[32,33,34],"code",{"__ignoreMap":30},[35,36,39,43,47,50],"span",{"class":37,"line":38},"line",1,[35,40,42],{"class":41},"sMTad","pip",[35,44,46],{"class":45},"srMev"," install",[35,48,49],{"class":45}," pandas",[35,51,52],{"class":45}," openpyxl\n",[10,54,55,58],{},[32,56,57],{},"price_observations.xlsx"," has one row per price check: item, date and unit price. Prices were recorded irregularly between January and September 2026 — some items several times in a month, some only once all year. The report needs a price for every item for every month from January to September.",[20,60,62],{"id":61},"step-1-one-observation-per-item-and-period","Step 1 — One observation per item and period",[10,64,65],{},"First reduce the raw observations to at most one value per item per month. When an item was checked twice in a month, the later check is the one that holds at month end:",[25,67,71],{"className":68,"code":69,"language":70,"meta":30,"style":30},"language-python shiki shiki-themes github-light github-dark-high-contrast","import pandas as pd\n\nobs = pd.read_excel(\"price_observations.xlsx\")\nobs[\"Month\"] = obs[\"Date\"].dt.to_period(\"M\")\nmonthly = (obs.sort_values(\"Date\")\n              .groupby([\"Item\", \"Month\"], as_index=False)\n              .last())\nprint(monthly[[\"Item\", \"Month\", \"Date\", \"Price\"]].to_string(index=False))\n","python",[32,72,73,89,96,114,142,157,186,192],{"__ignoreMap":30},[35,74,75,79,83,86],{"class":37,"line":38},[35,76,78],{"class":77},"s-kum","import",[35,80,82],{"class":81},"skGVy"," pandas ",[35,84,85],{"class":77},"as",[35,87,88],{"class":81}," pd\n",[35,90,92],{"class":37,"line":91},2,[35,93,95],{"emptyLinePlaceholder":94},true,"\n",[35,97,99,102,105,108,111],{"class":37,"line":98},3,[35,100,101],{"class":81},"obs ",[35,103,104],{"class":77},"=",[35,106,107],{"class":81}," pd.read_excel(",[35,109,110],{"class":45},"\"price_observations.xlsx\"",[35,112,113],{"class":81},")\n",[35,115,117,120,123,126,128,131,134,137,140],{"class":37,"line":116},4,[35,118,119],{"class":81},"obs[",[35,121,122],{"class":45},"\"Month\"",[35,124,125],{"class":81},"] ",[35,127,104],{"class":77},[35,129,130],{"class":81}," obs[",[35,132,133],{"class":45},"\"Date\"",[35,135,136],{"class":81},"].dt.to_period(",[35,138,139],{"class":45},"\"M\"",[35,141,113],{"class":81},[35,143,145,148,150,153,155],{"class":37,"line":144},5,[35,146,147],{"class":81},"monthly ",[35,149,104],{"class":77},[35,151,152],{"class":81}," (obs.sort_values(",[35,154,133],{"class":45},[35,156,113],{"class":81},[35,158,160,163,166,169,171,174,178,180,184],{"class":37,"line":159},6,[35,161,162],{"class":81},"              .groupby([",[35,164,165],{"class":45},"\"Item\"",[35,167,168],{"class":81},", ",[35,170,122],{"class":45},[35,172,173],{"class":81},"], ",[35,175,177],{"class":176},"sa561","as_index",[35,179,104],{"class":77},[35,181,183],{"class":182},"sP0c6","False",[35,185,113],{"class":81},[35,187,189],{"class":37,"line":188},7,[35,190,191],{"class":81},"              .last())\n",[35,193,195,198,201,203,205,207,209,211,213,216,219,222,224,226],{"class":37,"line":194},8,[35,196,197],{"class":182},"print",[35,199,200],{"class":81},"(monthly[[",[35,202,165],{"class":45},[35,204,168],{"class":81},[35,206,122],{"class":45},[35,208,168],{"class":81},[35,210,133],{"class":45},[35,212,168],{"class":81},[35,214,215],{"class":45},"\"Price\"",[35,217,218],{"class":81},"]].to_string(",[35,220,221],{"class":176},"index",[35,223,104],{"class":77},[35,225,183],{"class":182},[35,227,228],{"class":81},"))\n",[10,230,231,232,235],{},"Sorting by date and taking ",[32,233,234],{},"last()"," per item and month keeps the latest check — Bolt M8 was checked on 3 and 28 April, and the 28 April price of 0.135 is the April value. If your rule is different (the average of the month's readings, the first one), this is the place to apply it; the steps below only need one value per cell.",[20,237,239],{"id":238},"step-2-build-the-complete-item-month-grid","Step 2 — Build the complete item × month grid",[10,241,242],{},"Missing months are not rows at all yet — there is nothing to fill. Create every combination of item and month, then join the observations onto it:",[25,244,246],{"className":68,"code":245,"language":70,"meta":30,"style":30},"months = pd.period_range(\"2026-01\", \"2026-09\", freq=\"M\")\nitems = sorted(monthly[\"Item\"].unique())\ngrid = pd.MultiIndex.from_product([items, months], names=[\"Item\", \"Month\"]).to_frame(index=False)\ngrid = grid.merge(monthly[[\"Item\", \"Month\", \"Date\", \"Price\"]], on=[\"Item\", \"Month\"], how=\"left\")\nprint(grid.pivot(index=\"Item\", columns=\"Month\", values=\"Price\").to_string())\n",[32,247,248,277,295,330,381],{"__ignoreMap":30},[35,249,250,253,255,258,261,263,266,268,271,273,275],{"class":37,"line":38},[35,251,252],{"class":81},"months ",[35,254,104],{"class":77},[35,256,257],{"class":81}," pd.period_range(",[35,259,260],{"class":45},"\"2026-01\"",[35,262,168],{"class":81},[35,264,265],{"class":45},"\"2026-09\"",[35,267,168],{"class":81},[35,269,270],{"class":176},"freq",[35,272,104],{"class":77},[35,274,139],{"class":45},[35,276,113],{"class":81},[35,278,279,282,284,287,290,292],{"class":37,"line":91},[35,280,281],{"class":81},"items ",[35,283,104],{"class":77},[35,285,286],{"class":182}," sorted",[35,288,289],{"class":81},"(monthly[",[35,291,165],{"class":45},[35,293,294],{"class":81},"].unique())\n",[35,296,297,300,302,305,308,310,313,315,317,319,322,324,326,328],{"class":37,"line":98},[35,298,299],{"class":81},"grid ",[35,301,104],{"class":77},[35,303,304],{"class":81}," pd.MultiIndex.from_product([items, months], ",[35,306,307],{"class":176},"names",[35,309,104],{"class":77},[35,311,312],{"class":81},"[",[35,314,165],{"class":45},[35,316,168],{"class":81},[35,318,122],{"class":45},[35,320,321],{"class":81},"]).to_frame(",[35,323,221],{"class":176},[35,325,104],{"class":77},[35,327,183],{"class":182},[35,329,113],{"class":81},[35,331,332,334,336,339,341,343,345,347,349,351,353,356,359,361,363,365,367,369,371,374,376,379],{"class":37,"line":116},[35,333,299],{"class":81},[35,335,104],{"class":77},[35,337,338],{"class":81}," grid.merge(monthly[[",[35,340,165],{"class":45},[35,342,168],{"class":81},[35,344,122],{"class":45},[35,346,168],{"class":81},[35,348,133],{"class":45},[35,350,168],{"class":81},[35,352,215],{"class":45},[35,354,355],{"class":81},"]], ",[35,357,358],{"class":176},"on",[35,360,104],{"class":77},[35,362,312],{"class":81},[35,364,165],{"class":45},[35,366,168],{"class":81},[35,368,122],{"class":45},[35,370,173],{"class":81},[35,372,373],{"class":176},"how",[35,375,104],{"class":77},[35,377,378],{"class":45},"\"left\"",[35,380,113],{"class":81},[35,382,383,385,388,390,392,394,396,399,401,403,405,408,410,412],{"class":37,"line":144},[35,384,197],{"class":182},[35,386,387],{"class":81},"(grid.pivot(",[35,389,221],{"class":176},[35,391,104],{"class":77},[35,393,165],{"class":45},[35,395,168],{"class":81},[35,397,398],{"class":176},"columns",[35,400,104],{"class":77},[35,402,122],{"class":45},[35,404,168],{"class":81},[35,406,407],{"class":176},"values",[35,409,104],{"class":77},[35,411,215],{"class":45},[35,413,414],{"class":81},").to_string())\n",[10,416,417,420,421,424,425,428,429,18],{},[32,418,419],{},"MultiIndex.from_product"," creates all 5 × 9 = 45 item-month pairs; the left merge puts the observed price where there is one and ",[32,422,423],{},"NaN"," elsewhere. The pivoted view shows the raw sparseness: Rawl plug has a single January price, the drill bit nothing before May. Building the grid from an explicit ",[32,426,427],{},"period_range"," rather than from the months that happen to appear in the data means a month in which nothing at all was checked still gets a column. The same grid technique appears in ",[14,430,432],{"href":431},"\u002Fadvanced-data-transformation-and-cleaning\u002Fworking-with-dates-and-times-in-excel-data\u002Ffill-missing-dates-in-a-time-series-with-pandas\u002F","Fill Missing Dates in a Time Series with pandas",[20,434,436],{"id":435},"step-3-forward-fill-within-each-item-with-a-limit","Step 3 — Forward-fill within each item, with a limit",[10,438,439,440,443],{},"Now carry values forward — but only within the same item, and only for a limited number of months. ",[32,441,442],{},"groupby(\"Item\").ffill(limit=n)"," does both:",[25,445,447],{"className":68,"code":446,"language":70,"meta":30,"style":30},"MAX_CARRY = 3                                   # a price may be reused for at most 3 months\ngrid = grid.sort_values([\"Item\", \"Month\"])\ngrid[\"As of\"] = grid[\"Month\"].where(grid[\"Price\"].notna())\ngrid[[\"Price filled\", \"As of\"]] = grid.groupby(\"Item\")[[\"Price\", \"As of\"]].ffill(limit=MAX_CARRY)\ngrid[\"Age (months)\"] = [(m - a).n if pd.notna(a) else None for m, a in zip(grid[\"Month\"], grid[\"As of\"])]\nprint(grid[grid[\"Item\"].isin([\"Bolt M8\", \"Rawl plug\"])][[\"Item\", \"Month\", \"Price\", \"Price filled\", \"As of\", \"Age (months)\"]].to_string(index=False))\n",[32,448,449,464,482,507,550,607],{"__ignoreMap":30},[35,450,451,454,457,460],{"class":37,"line":38},[35,452,453],{"class":182},"MAX_CARRY",[35,455,456],{"class":77}," =",[35,458,459],{"class":182}," 3",[35,461,463],{"class":462},"s-wDw","                                   # a price may be reused for at most 3 months\n",[35,465,466,468,470,473,475,477,479],{"class":37,"line":91},[35,467,299],{"class":81},[35,469,104],{"class":77},[35,471,472],{"class":81}," grid.sort_values([",[35,474,165],{"class":45},[35,476,168],{"class":81},[35,478,122],{"class":45},[35,480,481],{"class":81},"])\n",[35,483,484,487,490,492,494,497,499,502,504],{"class":37,"line":98},[35,485,486],{"class":81},"grid[",[35,488,489],{"class":45},"\"As of\"",[35,491,125],{"class":81},[35,493,104],{"class":77},[35,495,496],{"class":81}," grid[",[35,498,122],{"class":45},[35,500,501],{"class":81},"].where(grid[",[35,503,215],{"class":45},[35,505,506],{"class":81},"].notna())\n",[35,508,509,512,515,517,519,522,524,527,529,532,534,536,538,541,544,546,548],{"class":37,"line":116},[35,510,511],{"class":81},"grid[[",[35,513,514],{"class":45},"\"Price filled\"",[35,516,168],{"class":81},[35,518,489],{"class":45},[35,520,521],{"class":81},"]] ",[35,523,104],{"class":77},[35,525,526],{"class":81}," grid.groupby(",[35,528,165],{"class":45},[35,530,531],{"class":81},")[[",[35,533,215],{"class":45},[35,535,168],{"class":81},[35,537,489],{"class":45},[35,539,540],{"class":81},"]].ffill(",[35,542,543],{"class":176},"limit",[35,545,104],{"class":77},[35,547,453],{"class":182},[35,549,113],{"class":81},[35,551,552,554,557,559,561,564,567,570,573,576,579,582,585,588,591,594,597,599,602,604],{"class":37,"line":144},[35,553,486],{"class":81},[35,555,556],{"class":45},"\"Age (months)\"",[35,558,125],{"class":81},[35,560,104],{"class":77},[35,562,563],{"class":81}," [(m ",[35,565,566],{"class":77},"-",[35,568,569],{"class":81}," a).n ",[35,571,572],{"class":77},"if",[35,574,575],{"class":81}," pd.notna(a) ",[35,577,578],{"class":77},"else",[35,580,581],{"class":182}," None",[35,583,584],{"class":77}," for",[35,586,587],{"class":81}," m, a ",[35,589,590],{"class":77},"in",[35,592,593],{"class":182}," zip",[35,595,596],{"class":81},"(grid[",[35,598,122],{"class":45},[35,600,601],{"class":81},"], grid[",[35,603,489],{"class":45},[35,605,606],{"class":81},"])]\n",[35,608,609,611,614,616,619,622,624,627,630,632,634,636,638,640,642,644,646,648,650,652,654,656,658,660],{"class":37,"line":159},[35,610,197],{"class":182},[35,612,613],{"class":81},"(grid[grid[",[35,615,165],{"class":45},[35,617,618],{"class":81},"].isin([",[35,620,621],{"class":45},"\"Bolt M8\"",[35,623,168],{"class":81},[35,625,626],{"class":45},"\"Rawl plug\"",[35,628,629],{"class":81},"])][[",[35,631,165],{"class":45},[35,633,168],{"class":81},[35,635,122],{"class":45},[35,637,168],{"class":81},[35,639,215],{"class":45},[35,641,168],{"class":81},[35,643,514],{"class":45},[35,645,168],{"class":81},[35,647,489],{"class":45},[35,649,168],{"class":81},[35,651,556],{"class":45},[35,653,218],{"class":81},[35,655,221],{"class":176},[35,657,104],{"class":77},[35,659,183],{"class":182},[35,661,228],{"class":81},[10,663,664,665,668,669,672,673,676,677,681],{},"Grouping by item before filling is what stops Bolt M8's last price leaking into the next item's first rows — a plain ",[32,666,667],{},"ffill()"," on the whole column would do exactly that, because it only looks at the previous row. ",[32,670,671],{},"limit=3"," fills at most three consecutive gaps after each observation; Rawl plug's January price fills February to April and May onwards stays blank. Filling the ",[32,674,675],{},"As of"," month alongside the price records ",[678,679,680],"em",{},"which"," observation each value came from, and the age — the number of months since that observation — falls out of the difference of two periods. Rows before an item's first observation stay blank, because a forward fill never looks ahead: there is no honest value for the drill bit in March.",[683,684,692,693,692,697,692,701,692,709,692,716,692,726,692,733,692,738,692,742,692,746,692,752,692,758,692,762,692,764,692,766,692,769,692,773,692,776,692,778,692,780,692,785,692,790,692,793,692,795,692,797,692,802,692,807,692,810,692,812],"svg",{"viewBox":685,"role":686,"ariaLabelledBy":687,"xmlns":690,"style":691},"4 0 752 211","img",[688,689],"st-carry-t","st-carry-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[694,695,696],"title",{"id":688},"How far one observation is carried",[698,699,700],"desc",{"id":689},"Rawl plug has a single price observed in January. With a carry limit of three months it fills February, March and April with ages 1, 2 and 3. From May the value is left blank instead of being reported as current.",[702,703],"rect",{"x":704,"y":705,"width":706,"height":707,"fill":708},"4","0","752","211","#ffffff",[37,710],{"x1":711,"y1":712,"x2":713,"y2":712,"stroke":714,"style":715},"76.0","105.5","684.0","var(--muted,#5b6780)","stroke-width:2.5px",[702,717],{"x":718,"y":719,"width":720,"height":721,"rx":722,"fill":723,"stroke":724,"style":725},"20.0","16.0","136.0","57.5","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[727,728,732],"text",{"x":729,"y":730,"style":731},"88.0","40.5","font-size:12.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Jan",[727,734,737],{"x":729,"y":735,"style":736},"59.0","font-size:11px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","0.020 observed",[37,739],{"x1":729,"y1":740,"x2":729,"y2":712,"stroke":714,"style":741},"73.5","stroke-width:1.5px",[743,744],"circle",{"cx":729,"cy":712,"r":745,"fill":724,"stroke":708,"style":725},"7.0",[702,747],{"x":748,"y":749,"width":720,"height":721,"rx":722,"fill":750,"stroke":751,"style":725},"166.0","137.5","#d9f4f1","var(--teal,#0f9488)",[727,753,757],{"x":754,"y":755,"style":756},"234.0","162.0","font-size:12.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Feb",[727,759,761],{"x":754,"y":760,"style":736},"180.5","carried · age 1",[37,763],{"x1":754,"y1":712,"x2":754,"y2":749,"stroke":714,"style":741},[743,765],{"cx":754,"cy":712,"r":745,"fill":751,"stroke":708,"style":725},[702,767],{"x":768,"y":719,"width":720,"height":721,"rx":722,"fill":750,"stroke":751,"style":725},"312.0",[727,770,772],{"x":771,"y":730,"style":756},"380.0","Mar",[727,774,775],{"x":771,"y":735,"style":736},"carried · age 2",[37,777],{"x1":771,"y1":740,"x2":771,"y2":712,"stroke":714,"style":741},[743,779],{"cx":771,"cy":712,"r":745,"fill":751,"stroke":708,"style":725},[702,781],{"x":782,"y":749,"width":720,"height":721,"rx":722,"fill":783,"stroke":784,"style":725},"458.0","#fdefd8","var(--gold,#b4740a)",[727,786,789],{"x":787,"y":755,"style":788},"526.0","font-size:12.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Apr",[727,791,792],{"x":787,"y":760,"style":736},"carried · age 3",[37,794],{"x1":787,"y1":712,"x2":787,"y2":749,"stroke":714,"style":741},[743,796],{"cx":787,"cy":712,"r":745,"fill":784,"stroke":708,"style":725},[702,798],{"x":799,"y":719,"width":720,"height":721,"rx":722,"fill":800,"stroke":801,"style":725},"604.0","#fce9e9","var(--danger,#dc2626)",[727,803,806],{"x":804,"y":730,"style":805},"672.0","font-size:12.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","May →",[727,808,809],{"x":804,"y":735,"style":736},"blank: too old",[37,811],{"x1":804,"y1":740,"x2":804,"y2":712,"stroke":714,"style":741},[743,813],{"cx":804,"cy":712,"r":745,"fill":801,"stroke":708,"style":725},[683,815,692,820,692,823,692,826,692,829,692,834,692,845,692,851,692,857,692,862,692,866,692,870,692,873,692,880,692,885,692,889,692,892,692,895],{"viewBox":816,"role":686,"ariaLabelledBy":817,"xmlns":690,"style":691},"4 0 752 207",[818,819],"st-ways-t","st-ways-d",[694,821,822],{"id":818},"A plain forward fill versus a grouped, limited one",[698,824,825],{"id":819},"A plain ffill on the whole column carries the last value of one item into the next item's first rows and keeps old values forever. Grouping by item stops the leak, the limit stops values living forever, and the as-of column records each value's age.",[702,827],{"x":704,"y":705,"width":706,"height":828,"fill":708},"207",[702,830],{"x":718,"y":719,"width":831,"height":832,"rx":833,"fill":800,"stroke":801,"style":725},"351.0","175.0","14",[835,836,838],"g",{"transform":837},"translate(182.5,29.0) scale(1.083)",[839,840],"path",{"d":841,"fill":842,"stroke":843,"style":844},"M6 6l12 12 M18 6L6 18","none","var(--accent-ink,#be185d)","stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[727,846,850],{"x":847,"y":848,"style":849},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","df['Price'].ffill()",[37,852],{"x1":853,"y1":854,"x2":855,"y2":854,"stroke":801,"style":856},"36.0","91.0","355.0","stroke-width:1px",[727,858,861],{"x":847,"y":859,"style":860},"111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","Bolt price leaks into the drill bit",[727,863,865],{"x":847,"y":864,"style":860},"137.0","January value still 'current' in September",[727,867,869],{"x":847,"y":868,"style":860},"163.0","no record of where a value came from",[702,871],{"x":872,"y":719,"width":831,"height":832,"rx":833,"fill":750,"stroke":751,"style":725},"389.0",[835,874,876],{"transform":875},"translate(551.5,29.0) scale(1.083)",[839,877],{"d":878,"fill":842,"stroke":879,"style":844},"M5 12.5l4.5 4.5L19 7","var(--teal-ink,#0b6157)",[727,881,884],{"x":882,"y":848,"style":883},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","groupby('Item').ffill(limit=3)",[37,886],{"x1":887,"y1":854,"x2":888,"y2":854,"stroke":751,"style":856},"405.0","724.0",[727,890,891],{"x":882,"y":859,"style":860},"fills within each item only",[727,893,894],{"x":882,"y":864,"style":860},"at most three months of carrying",[727,896,897],{"x":882,"y":868,"style":860},"as-of month gives each value's age",[20,899,901],{"id":900},"step-4-flag-stale-values","Step 4 — Flag stale values",[10,903,904,905,908,909,912,913,916,917,920],{},"Within the limit, older values deserve less trust. A simple rule marks a value as ",[678,906,907],{},"fresh"," when observed this month, ",[678,910,911],{},"carried"," for one or two months, and ",[678,914,915],{},"stale"," at the limit; anything beyond the limit is ",[678,918,919],{},"missing",":",[25,922,924],{"className":68,"code":923,"language":70,"meta":30,"style":30},"def status(age):\n    if pd.isna(age):\n        return \"missing\"\n    return \"observed\" if age == 0 else \"carried\" if age \u003C MAX_CARRY else \"stale\"\n\ngrid[\"Status\"] = grid[\"Age (months)\"].map(status)\nprint(grid[\"Status\"].value_counts().to_dict())\nprint(grid.pivot(index=\"Item\", columns=\"Month\", values=\"Status\").to_string())\n",[32,925,926,938,946,954,995,999,1017,1028],{"__ignoreMap":30},[35,927,928,931,935],{"class":37,"line":38},[35,929,930],{"class":77},"def",[35,932,934],{"class":933},"s_Opv"," status",[35,936,937],{"class":81},"(age):\n",[35,939,940,943],{"class":37,"line":91},[35,941,942],{"class":77},"    if",[35,944,945],{"class":81}," pd.isna(age):\n",[35,947,948,951],{"class":37,"line":98},[35,949,950],{"class":77},"        return",[35,952,953],{"class":45}," \"missing\"\n",[35,955,956,959,962,965,968,971,974,977,980,982,984,987,990,992],{"class":37,"line":116},[35,957,958],{"class":77},"    return",[35,960,961],{"class":45}," \"observed\"",[35,963,964],{"class":77}," if",[35,966,967],{"class":81}," age ",[35,969,970],{"class":77},"==",[35,972,973],{"class":182}," 0",[35,975,976],{"class":77}," else",[35,978,979],{"class":45}," \"carried\"",[35,981,964],{"class":77},[35,983,967],{"class":81},[35,985,986],{"class":77},"\u003C",[35,988,989],{"class":182}," MAX_CARRY",[35,991,976],{"class":77},[35,993,994],{"class":45}," \"stale\"\n",[35,996,997],{"class":37,"line":144},[35,998,95],{"emptyLinePlaceholder":94},[35,1000,1001,1003,1006,1008,1010,1012,1014],{"class":37,"line":159},[35,1002,486],{"class":81},[35,1004,1005],{"class":45},"\"Status\"",[35,1007,125],{"class":81},[35,1009,104],{"class":77},[35,1011,496],{"class":81},[35,1013,556],{"class":45},[35,1015,1016],{"class":81},"].map(status)\n",[35,1018,1019,1021,1023,1025],{"class":37,"line":188},[35,1020,197],{"class":182},[35,1022,596],{"class":81},[35,1024,1005],{"class":45},[35,1026,1027],{"class":81},"].value_counts().to_dict())\n",[35,1029,1030,1032,1034,1036,1038,1040,1042,1044,1046,1048,1050,1052,1054,1056],{"class":37,"line":194},[35,1031,197],{"class":182},[35,1033,387],{"class":81},[35,1035,221],{"class":176},[35,1037,104],{"class":77},[35,1039,165],{"class":45},[35,1041,168],{"class":81},[35,1043,398],{"class":176},[35,1045,104],{"class":77},[35,1047,122],{"class":45},[35,1049,168],{"class":81},[35,1051,407],{"class":176},[35,1053,104],{"class":77},[35,1055,1005],{"class":45},[35,1057,414],{"class":81},[10,1059,1060,1061,1063],{},"The status matrix is the honest summary of how good the price list is: only 10 of the 45 cells were actually observed in their month, 13 are carried, 4 are stale and 18 have no usable price — and it shows at a glance which items need a fresh price: Nut M8 and Rawl plug have nothing usable after the spring, and Bolt M8 drops out in August. Washer M8 is a good example — checked in March and then not again until August, it is carried, then stale, then missing in July before the August check refreshes it. The thresholds are business decisions: a three-month-old exchange rate is useless, a three-month-old screw price is probably fine. Make ",[32,1062,453],{}," a per-item or per-category setting if items differ.",[683,1065,692,1070,692,1073,692,1076,692,1079,692,1084,692,1089,692,1094,692,1097,692,1100,692,1103,692,1107,692,1110,692,1114,692,1117,692,1121,692,1126,692,1131,692,1133,692,1138,692,1140,692,1142,692,1144,692,1146,692,1148,692,1150,692,1152,692,1154,692,1157,692,1161,692,1163,692,1165,692,1167,692,1169,692,1171,692,1173,692,1175,692,1177,692,1179,692,1181,692,1184,692,1188,692,1190,692,1193,692,1195,692,1197,692,1199,692,1201,692,1203,692,1205,692,1207,692,1209,692,1212,692,1216,692,1218,692,1221,692,1223,692,1225,692,1227,692,1229,692,1231,692,1233,692,1235],{"viewBox":1066,"role":686,"ariaLabelledBy":1067,"xmlns":690,"style":691},"4 0 752 208",[1068,1069],"st-status-t","st-status-d",[694,1071,1072],{"id":1068},"Status of each item and month",[698,1074,1075],{"id":1069},"For each item and month the cell shows whether the price was observed that month, carried from up to two months earlier, stale at the three-month limit, or missing. Rawl plug is missing from May. Drill bit is missing before its first observation in May.",[702,1077],{"x":704,"y":705,"width":706,"height":1078,"fill":708},"208",[702,1080],{"x":718,"y":719,"width":1081,"height":1082,"rx":705,"fill":708,"stroke":1083,"style":856},"108.9","40.0","var(--line,#cdd5e6)",[702,1085],{"x":1086,"y":719,"width":1087,"height":1082,"rx":705,"fill":723,"stroke":724,"style":1088},"128.9","122.2","stroke-width:1.2px",[727,1090,732],{"x":1091,"y":1092,"style":1093},"190.0","41.0","font-size:13px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle",[702,1095],{"x":1096,"y":719,"width":1087,"height":1082,"rx":705,"fill":723,"stroke":724,"style":1088},"251.1",[727,1098,772],{"x":1099,"y":1092,"style":1093},"312.2",[702,1101],{"x":1102,"y":719,"width":1087,"height":1082,"rx":705,"fill":723,"stroke":724,"style":1088},"373.3",[727,1104,1106],{"x":1105,"y":1092,"style":1093},"434.4","May",[702,1108],{"x":1109,"y":719,"width":1087,"height":1082,"rx":705,"fill":723,"stroke":724,"style":1088},"495.5",[727,1111,1113],{"x":1112,"y":1092,"style":1093},"556.7","Jul",[702,1115],{"x":1116,"y":719,"width":1087,"height":1082,"rx":705,"fill":723,"stroke":724,"style":1088},"617.8",[727,1118,1120],{"x":1119,"y":1092,"style":1093},"678.9","Sep",[702,1122],{"x":718,"y":1123,"width":1081,"height":1124,"rx":705,"fill":1125,"stroke":1083,"style":856},"56.0","34.0","#e7ebef",[727,1127,1130],{"x":1128,"y":848,"style":1129},"32.0","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:start","Bolt M8",[702,1132],{"x":1086,"y":1123,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1134,1137],{"x":1091,"y":1135,"style":1136},"77.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","observed",[702,1139],{"x":1096,"y":1123,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1141,911],{"x":1099,"y":1135,"style":1136},[702,1143],{"x":1102,"y":1123,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1145,911],{"x":1105,"y":1135,"style":1136},[702,1147],{"x":1109,"y":1123,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1149,915],{"x":1112,"y":1135,"style":1136},[702,1151],{"x":1116,"y":1123,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1153,919],{"x":1119,"y":1135,"style":1136},[702,1155],{"x":718,"y":1156,"width":1081,"height":1124,"rx":705,"fill":1125,"stroke":1083,"style":856},"90.0",[727,1158,1160],{"x":1128,"y":1159,"style":1129},"112.0","Washer M8",[702,1162],{"x":1086,"y":1156,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1164,919],{"x":1091,"y":859,"style":1136},[702,1166],{"x":1096,"y":1156,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1168,1137],{"x":1099,"y":859,"style":1136},[702,1170],{"x":1102,"y":1156,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1172,911],{"x":1105,"y":859,"style":1136},[702,1174],{"x":1109,"y":1156,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1176,919],{"x":1112,"y":859,"style":1136},[702,1178],{"x":1116,"y":1156,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1180,911],{"x":1119,"y":859,"style":1136},[702,1182],{"x":718,"y":1183,"width":1081,"height":1124,"rx":705,"fill":1125,"stroke":1083,"style":856},"124.0",[727,1185,1187],{"x":1128,"y":1186,"style":1129},"146.0","Drill bit 8mm",[702,1189],{"x":1086,"y":1183,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1191,919],{"x":1091,"y":1192,"style":1136},"145.0",[702,1194],{"x":1096,"y":1183,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1196,919],{"x":1099,"y":1192,"style":1136},[702,1198],{"x":1102,"y":1183,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1200,1137],{"x":1105,"y":1192,"style":1136},[702,1202],{"x":1109,"y":1183,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1204,911],{"x":1112,"y":1192,"style":1136},[702,1206],{"x":1116,"y":1183,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1208,1137],{"x":1119,"y":1192,"style":1136},[702,1210],{"x":718,"y":1211,"width":1081,"height":1124,"rx":705,"fill":1125,"stroke":1083,"style":856},"158.0",[727,1213,1215],{"x":1128,"y":1214,"style":1129},"180.0","Rawl plug",[702,1217],{"x":1086,"y":1211,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1219,1137],{"x":1091,"y":1220,"style":1136},"179.0",[702,1222],{"x":1096,"y":1211,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1224,911],{"x":1099,"y":1220,"style":1136},[702,1226],{"x":1102,"y":1211,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1228,919],{"x":1105,"y":1220,"style":1136},[702,1230],{"x":1109,"y":1211,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1232,919],{"x":1112,"y":1220,"style":1136},[702,1234],{"x":1116,"y":1211,"width":1087,"height":1124,"rx":705,"fill":708,"stroke":1083,"style":856},[727,1236,919],{"x":1119,"y":1220,"style":1136},[20,1238,1240],{"id":1239},"step-5-write-a-price-matrix-that-shows-its-own-quality","Step 5 — Write a price matrix that shows its own quality",[10,1242,1243],{},"The report sheet shows prices by item and month, with each cell's background saying how old the value is, and a second sheet with the full detail:",[25,1245,1247],{"className":68,"code":1246,"language":70,"meta":30,"style":30},"from openpyxl.styles import Font, PatternFill\n\nprices = grid.pivot(index=\"Item\", columns=\"Month\", values=\"Price filled\")\nstatus_m = grid.pivot(index=\"Item\", columns=\"Month\", values=\"Status\")\nprices.columns = [str(c) for c in prices.columns]\nfills = {\"carried\": \"FFF2CC\", \"stale\": \"F8CBAD\", \"missing\": \"D9D9D9\"}\n\nwith pd.ExcelWriter(\"price_matrix.xlsx\", engine=\"openpyxl\") as xw:\n    prices.to_excel(xw, sheet_name=\"Prices\")\n    grid.assign(Month=grid[\"Month\"].astype(str), **{\"As of\": grid[\"As of\"].astype(str)}) \\\n        .drop(columns=\"Date\").to_excel(xw, sheet_name=\"Detail\", index=False)\n    ws = xw.sheets[\"Prices\"]\n    for r, item in enumerate(prices.index, start=2):\n        for c, month in enumerate(status_m.columns, start=2):\n            s = status_m.loc[item, month]\n            if s in fills:\n                ws.cell(r, c).fill = PatternFill(\"solid\", fgColor=fills[s])\n            ws.cell(r, c).number_format = \"0.000\"\n    ws[\"A12\"] = \"White: observed that month · yellow: carried · orange: stale · grey: no recent price\"\n    ws[\"A12\"].font = Font(italic=True)\n    ws.column_dimensions[\"A\"].width = 16\nprint(prices.round(3).to_string())\n",[32,1248,1249,1262,1266,1300,1333,1360,1402,1406,1435,1451,1494,1526,1542,1570,1594,1605,1619,1643,1654,1670,1695,1712],{"__ignoreMap":30},[35,1250,1251,1254,1257,1259],{"class":37,"line":38},[35,1252,1253],{"class":77},"from",[35,1255,1256],{"class":81}," openpyxl.styles ",[35,1258,78],{"class":77},[35,1260,1261],{"class":81}," Font, PatternFill\n",[35,1263,1264],{"class":37,"line":91},[35,1265,95],{"emptyLinePlaceholder":94},[35,1267,1268,1271,1273,1276,1278,1280,1282,1284,1286,1288,1290,1292,1294,1296,1298],{"class":37,"line":98},[35,1269,1270],{"class":81},"prices ",[35,1272,104],{"class":77},[35,1274,1275],{"class":81}," grid.pivot(",[35,1277,221],{"class":176},[35,1279,104],{"class":77},[35,1281,165],{"class":45},[35,1283,168],{"class":81},[35,1285,398],{"class":176},[35,1287,104],{"class":77},[35,1289,122],{"class":45},[35,1291,168],{"class":81},[35,1293,407],{"class":176},[35,1295,104],{"class":77},[35,1297,514],{"class":45},[35,1299,113],{"class":81},[35,1301,1302,1305,1307,1309,1311,1313,1315,1317,1319,1321,1323,1325,1327,1329,1331],{"class":37,"line":116},[35,1303,1304],{"class":81},"status_m ",[35,1306,104],{"class":77},[35,1308,1275],{"class":81},[35,1310,221],{"class":176},[35,1312,104],{"class":77},[35,1314,165],{"class":45},[35,1316,168],{"class":81},[35,1318,398],{"class":176},[35,1320,104],{"class":77},[35,1322,122],{"class":45},[35,1324,168],{"class":81},[35,1326,407],{"class":176},[35,1328,104],{"class":77},[35,1330,1005],{"class":45},[35,1332,113],{"class":81},[35,1334,1335,1338,1340,1343,1346,1349,1352,1355,1357],{"class":37,"line":144},[35,1336,1337],{"class":81},"prices.columns ",[35,1339,104],{"class":77},[35,1341,1342],{"class":81}," [",[35,1344,1345],{"class":182},"str",[35,1347,1348],{"class":81},"(c) ",[35,1350,1351],{"class":77},"for",[35,1353,1354],{"class":81}," c ",[35,1356,590],{"class":77},[35,1358,1359],{"class":81}," prices.columns]\n",[35,1361,1362,1365,1367,1370,1373,1376,1379,1381,1384,1386,1389,1391,1394,1396,1399],{"class":37,"line":159},[35,1363,1364],{"class":81},"fills ",[35,1366,104],{"class":77},[35,1368,1369],{"class":81}," {",[35,1371,1372],{"class":45},"\"carried\"",[35,1374,1375],{"class":81},": ",[35,1377,1378],{"class":45},"\"FFF2CC\"",[35,1380,168],{"class":81},[35,1382,1383],{"class":45},"\"stale\"",[35,1385,1375],{"class":81},[35,1387,1388],{"class":45},"\"F8CBAD\"",[35,1390,168],{"class":81},[35,1392,1393],{"class":45},"\"missing\"",[35,1395,1375],{"class":81},[35,1397,1398],{"class":45},"\"D9D9D9\"",[35,1400,1401],{"class":81},"}\n",[35,1403,1404],{"class":37,"line":188},[35,1405,95],{"emptyLinePlaceholder":94},[35,1407,1408,1411,1414,1417,1419,1422,1424,1427,1430,1432],{"class":37,"line":194},[35,1409,1410],{"class":77},"with",[35,1412,1413],{"class":81}," pd.ExcelWriter(",[35,1415,1416],{"class":45},"\"price_matrix.xlsx\"",[35,1418,168],{"class":81},[35,1420,1421],{"class":176},"engine",[35,1423,104],{"class":77},[35,1425,1426],{"class":45},"\"openpyxl\"",[35,1428,1429],{"class":81},") ",[35,1431,85],{"class":77},[35,1433,1434],{"class":81}," xw:\n",[35,1436,1438,1441,1444,1446,1449],{"class":37,"line":1437},9,[35,1439,1440],{"class":81},"    prices.to_excel(xw, ",[35,1442,1443],{"class":176},"sheet_name",[35,1445,104],{"class":77},[35,1447,1448],{"class":45},"\"Prices\"",[35,1450,113],{"class":81},[35,1452,1454,1457,1460,1462,1464,1466,1469,1471,1474,1477,1480,1482,1485,1487,1489,1491],{"class":37,"line":1453},10,[35,1455,1456],{"class":81},"    grid.assign(",[35,1458,1459],{"class":176},"Month",[35,1461,104],{"class":77},[35,1463,486],{"class":81},[35,1465,122],{"class":45},[35,1467,1468],{"class":81},"].astype(",[35,1470,1345],{"class":182},[35,1472,1473],{"class":81},"), ",[35,1475,1476],{"class":77},"**",[35,1478,1479],{"class":81},"{",[35,1481,489],{"class":45},[35,1483,1484],{"class":81},": grid[",[35,1486,489],{"class":45},[35,1488,1468],{"class":81},[35,1490,1345],{"class":182},[35,1492,1493],{"class":81},")}) \\\n",[35,1495,1497,1500,1502,1504,1506,1509,1511,1513,1516,1518,1520,1522,1524],{"class":37,"line":1496},11,[35,1498,1499],{"class":81},"        .drop(",[35,1501,398],{"class":176},[35,1503,104],{"class":77},[35,1505,133],{"class":45},[35,1507,1508],{"class":81},").to_excel(xw, ",[35,1510,1443],{"class":176},[35,1512,104],{"class":77},[35,1514,1515],{"class":45},"\"Detail\"",[35,1517,168],{"class":81},[35,1519,221],{"class":176},[35,1521,104],{"class":77},[35,1523,183],{"class":182},[35,1525,113],{"class":81},[35,1527,1529,1532,1534,1537,1539],{"class":37,"line":1528},12,[35,1530,1531],{"class":81},"    ws ",[35,1533,104],{"class":77},[35,1535,1536],{"class":81}," xw.sheets[",[35,1538,1448],{"class":45},[35,1540,1541],{"class":81},"]\n",[35,1543,1545,1548,1551,1553,1556,1559,1562,1564,1567],{"class":37,"line":1544},13,[35,1546,1547],{"class":77},"    for",[35,1549,1550],{"class":81}," r, item ",[35,1552,590],{"class":77},[35,1554,1555],{"class":182}," enumerate",[35,1557,1558],{"class":81},"(prices.index, ",[35,1560,1561],{"class":176},"start",[35,1563,104],{"class":77},[35,1565,1566],{"class":182},"2",[35,1568,1569],{"class":81},"):\n",[35,1571,1573,1576,1579,1581,1583,1586,1588,1590,1592],{"class":37,"line":1572},14,[35,1574,1575],{"class":77},"        for",[35,1577,1578],{"class":81}," c, month ",[35,1580,590],{"class":77},[35,1582,1555],{"class":182},[35,1584,1585],{"class":81},"(status_m.columns, ",[35,1587,1561],{"class":176},[35,1589,104],{"class":77},[35,1591,1566],{"class":182},[35,1593,1569],{"class":81},[35,1595,1597,1600,1602],{"class":37,"line":1596},15,[35,1598,1599],{"class":81},"            s ",[35,1601,104],{"class":77},[35,1603,1604],{"class":81}," status_m.loc[item, month]\n",[35,1606,1608,1611,1614,1616],{"class":37,"line":1607},16,[35,1609,1610],{"class":77},"            if",[35,1612,1613],{"class":81}," s ",[35,1615,590],{"class":77},[35,1617,1618],{"class":81}," fills:\n",[35,1620,1622,1625,1627,1630,1633,1635,1638,1640],{"class":37,"line":1621},17,[35,1623,1624],{"class":81},"                ws.cell(r, c).fill ",[35,1626,104],{"class":77},[35,1628,1629],{"class":81}," PatternFill(",[35,1631,1632],{"class":45},"\"solid\"",[35,1634,168],{"class":81},[35,1636,1637],{"class":176},"fgColor",[35,1639,104],{"class":77},[35,1641,1642],{"class":81},"fills[s])\n",[35,1644,1646,1649,1651],{"class":37,"line":1645},18,[35,1647,1648],{"class":81},"            ws.cell(r, c).number_format ",[35,1650,104],{"class":77},[35,1652,1653],{"class":45}," \"0.000\"\n",[35,1655,1657,1660,1663,1665,1667],{"class":37,"line":1656},19,[35,1658,1659],{"class":81},"    ws[",[35,1661,1662],{"class":45},"\"A12\"",[35,1664,125],{"class":81},[35,1666,104],{"class":77},[35,1668,1669],{"class":45}," \"White: observed that month · yellow: carried · orange: stale · grey: no recent price\"\n",[35,1671,1673,1675,1677,1680,1682,1685,1688,1690,1693],{"class":37,"line":1672},20,[35,1674,1659],{"class":81},[35,1676,1662],{"class":45},[35,1678,1679],{"class":81},"].font ",[35,1681,104],{"class":77},[35,1683,1684],{"class":81}," Font(",[35,1686,1687],{"class":176},"italic",[35,1689,104],{"class":77},[35,1691,1692],{"class":182},"True",[35,1694,113],{"class":81},[35,1696,1698,1701,1704,1707,1709],{"class":37,"line":1697},21,[35,1699,1700],{"class":81},"    ws.column_dimensions[",[35,1702,1703],{"class":45},"\"A\"",[35,1705,1706],{"class":81},"].width ",[35,1708,104],{"class":77},[35,1710,1711],{"class":182}," 16\n",[35,1713,1715,1717,1720,1723],{"class":37,"line":1714},22,[35,1716,197],{"class":182},[35,1718,1719],{"class":81},"(prices.round(",[35,1721,1722],{"class":182},"3",[35,1724,414],{"class":81},[10,1726,1727],{},"Readers see the price table they expect, but the colours tell them which prices were checked recently and which are leftovers, and a legend under the table explains the colours so the sheet makes sense on its own. The Detail sheet keeps the as-of month and age for every cell, so a question about any figure — \"where does this 0.13 come from?\" — can be answered from the workbook itself. Periods are converted to strings before writing, since Excel has no period type. When next month's checks arrive, append them to the observations file and rerun: the grid, the fill and the colours are all recomputed, so a refreshed price turns its cell white again without anyone editing the sheet.",[20,1729,1731],{"id":1730},"common-pitfalls","Common pitfalls",[1733,1734,1735,1746,1752,1758,1764],"ul",{},[1736,1737,1738,1745],"li",{},[1739,1740,1741,1742,1744],"strong",{},"A plain ",[32,1743,667],{}," across items."," Values leak between items; always group by the item first.",[1736,1747,1748,1751],{},[1739,1749,1750],{},"Filling without a limit."," Old values are reported as current indefinitely.",[1736,1753,1754,1757],{},[1739,1755,1756],{},"Back-filling before the first observation."," There is no honest value there; leave it blank.",[1736,1759,1760,1763],{},[1739,1761,1762],{},"Gaps that are not rows."," Build the full grid before filling.",[1736,1765,1766,1769],{},[1739,1767,1768],{},"Hiding the age."," Keep the as-of period and show stale values differently.",[20,1771,1773],{"id":1772},"conclusion","Conclusion",[10,1775,1776,1777,1779,1780,1783],{},"To complete a sparse per-item time series, reduce observations to one per item and period, build the full item-by-period grid with ",[32,1778,419],{},", and forward-fill within each item using ",[32,1781,1782],{},"groupby().ffill(limit=n)"," so values neither leak between items nor live forever. Fill an as-of period alongside the value to compute its age, classify each cell as observed, carried, stale or missing, and write a matrix whose colours make the quality of every figure visible.",[20,1785,1787],{"id":1786},"frequently-asked-questions","Frequently asked questions",[10,1789,1790,1793,1794,1797],{},[1739,1791,1792],{},"How do I forward-fill values per group in pandas?","\nSort by group and period, then call df.groupby(\"Item\")",[35,1795,1796],{},"cols",".ffill(). Grouping stops the last value of one item filling the first rows of the next.",[10,1799,1800,1803],{},[1739,1801,1802],{},"How do I limit how far a value is carried forward?","\nPass limit to ffill, for example ffill(limit=3), which fills at most three consecutive missing periods after each known value.",[10,1805,1806,1809],{},[1739,1807,1808],{},"How do I know how old a forward-filled value is?","\nFill the observation period alongside the value, then subtract it from each row's period to get the age in periods.",[10,1811,1812,1815],{},[1739,1813,1814],{},"Why are some periods still blank after filling?","\nEither they come before the item's first observation, which a forward fill never fills, or they are further from the last observation than the limit allows.",[20,1817,1819],{"id":1818},"related","Related",[1733,1821,1822,1828,1835,1842],{},[1736,1823,1824,1825,1827],{},"Up one level: ",[14,1826,17],{"href":16}," — finding and treating gaps.",[1736,1829,1830,1834],{},[14,1831,1833],{"href":1832},"\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Fforward-fill-blank-cells-in-grouped-excel-reports\u002F","Forward-Fill Blank Cells in Grouped Excel Reports"," — filling layout blanks rather than time gaps.",[1736,1836,1837,1841],{},[14,1838,1840],{"href":1839},"\u002Fadvanced-data-transformation-and-cleaning\u002Fmerging-and-joining-excel-dataframes\u002Fmatch-rows-by-nearest-date-with-merge-asof\u002F","Match Rows by Nearest Date with merge_asof"," — looking up the latest value on a date, with a tolerance.",[1736,1843,1844,1848],{},[14,1845,1847],{"href":1846},"\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Finterpolate-missing-numeric-values-in-excel-data\u002F","Interpolate Missing Numeric Values in Excel Data"," — estimating between known points instead.",[1850,1851,1852],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}",{"title":30,"searchDepth":91,"depth":91,"links":1854},[1855,1856,1857,1858,1859,1860,1861,1862,1863,1864],{"id":22,"depth":91,"text":23},{"id":61,"depth":91,"text":62},{"id":238,"depth":91,"text":239},{"id":435,"depth":91,"text":436},{"id":900,"depth":91,"text":901},{"id":1239,"depth":91,"text":1240},{"id":1730,"depth":91,"text":1731},{"id":1772,"depth":91,"text":1773},{"id":1786,"depth":91,"text":1787},{"id":1818,"depth":91,"text":1819},"2026-10-02","Carry the last known value forward for each item in a sparse Excel time series without letting old numbers pose as current: build a complete item-by-period grid, forward-fill within each item only, cap how many periods a value may be carried, record each value's age and source period, flag stale cells, and write a price matrix to Excel that shows which figures are estimates.","md",[1869,1871,1873,1875],{"q":1792,"a":1870},"Sort by group and period, then call df.groupby(\"Item\")[cols].ffill(). Grouping stops the last value of one item filling the first rows of the next.",{"q":1802,"a":1872},"Pass limit to ffill, for example ffill(limit=3), which fills at most three consecutive missing periods after each known value.",{"q":1808,"a":1874},"Fill the observation period alongside the value, then subtract it from each row's period to get the age in periods.",{"q":1814,"a":1876},"Either they come before the item's first observation, which a forward fill never fills, or they are further from the last observation than the limit allows.","fill gaps in per-item time series with a staleness limit",null,{"breadcrumb":1880},[1881,1884,1887],{"name":1882,"item":1883},"Home","\u002F",{"name":1885,"item":1886},"Advanced Data Transformation and Cleaning for Excel Automation","\u002Fadvanced-data-transformation-and-cleaning\u002F",{"name":1888,"item":16},"Handling Missing Data in Excel Reports with Pandas","\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Ffill-gaps-in-per-item-time-series-with-a-staleness-limit",{"title":1891,"description":1892},"Forward-Fill Per-Item Time Series with a Staleness Limit","Complete a sparse item × month table in pandas: reindex to a full grid, groupby ffill with limit, value age and as-of period, stale flags, never filling before the first observation, and an Excel matrix.","fill-gaps-in-per-item-time-series-with-a-staleness-limit","advanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Ffill-gaps-in-per-item-time-series-with-a-staleness-limit\u002Findex",[1896,1897],"missing-data","dates-and-times","how-to","nYRdJFggWMDespufcrWwGpQadEgb8VqEC5uczu2G5Qg",[1901,1905],{"title":1902,"path":1903,"stem":1904,"children":-1},"Drop Rows with Missing Required Fields in pandas","\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Fdrop-rows-with-missing-required-fields-in-pandas","advanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Fdrop-rows-with-missing-required-fields-in-pandas\u002Findex",{"title":1906,"path":1907,"stem":1908,"children":-1},"Fill Missing Values in Excel with Pandas fillna","\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Ffill-missing-values-in-excel-with-pandas-fillna","advanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Ffill-missing-values-in-excel-with-pandas-fillna\u002Findex",[1910,1912],{"path":1911,"title":1888},"\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports",{"path":1913,"title":1885},"\u002Fadvanced-data-transformation-and-cleaning",[],[1916,1919,1922,1925,1926,1927],{"path":1917,"title":1918,"kind":1898},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fhighlight-blank-cells-with-openpyxl","Highlight Blank Cells in Excel with openpyxl",{"path":1920,"title":1921,"kind":1898},"\u002Fadvanced-data-transformation-and-cleaning\u002Fcleaning-excel-data-with-pandas\u002Fcoalesce-several-excel-columns-into-one-with-pandas","Coalesce Several Excel Columns into One with pandas",{"path":1923,"title":1924,"kind":1898},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fiferror-and-isna-equivalents-in-pandas","IFERROR, IFNA, ISNA and ISNUMBER Equivalents in pandas",{"path":1903,"title":1902,"kind":1898},{"path":1907,"title":1906,"kind":1898},{"path":1928,"title":1929,"kind":1898},"\u002Fadvanced-data-transformation-and-cleaning\u002Fhandling-missing-data-in-excel-reports\u002Ffind-and-report-missing-values-in-an-excel-file","Find and Report Missing Values in an Excel File",1790972095601]