[{"data":1,"prerenderedAt":2362},["ShallowReactive",2],{"doc:\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Frun-what-if-scenarios-on-an-excel-model-with-python":3,"surround:\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Frun-what-if-scenarios-on-an-excel-model-with-python":2328,"ancestors:\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Frun-what-if-scenarios-on-an-excel-model-with-python":2337,"children:\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Frun-what-if-scenarios-on-an-excel-model-with-python":2342,"related:\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Frun-what-if-scenarios-on-an-excel-model-with-python":2343},{"id":4,"title":5,"body":6,"dateModified":2295,"datePublished":2295,"description":2296,"extension":2297,"faq":2298,"keyword":2307,"kind":2308,"meta":2309,"navigation":415,"path":2317,"seo":2318,"slug":2321,"stem":2322,"topics":2323,"type":2326,"__hash__":2327},"docs\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Frun-what-if-scenarios-on-an-excel-model-with-python\u002Findex.md","Run What-If Scenarios on an Excel Model with Python",{"type":7,"value":8,"toc":2281},"minimark",[9,19,24,74,88,270,274,281,539,554,558,561,849,864,868,871,931,946,950,956,1091,1094,1203,1207,1210,1392,1445,1665,1668,1760,1764,1767,1952,1955,1959,1962,2137,2144,2148,2206,2210,2216,2220,2226,2232,2238,2244,2248,2277],[10,11,12,13,18],"p",{},"Finance and operations teams keep their models in Excel for good reasons: the logic is visible, reviewers can click a cell and see the formula, and the people who own the assumptions can change them. What Excel is bad at is running the model many times — best case, worst case, the board's case, every input nudged up and down by ten percent. Scenario Manager and Data Tables exist, but they are fiddly, hard to review and do not scale to dozens of runs. Rewriting the model in Python solves the scale problem and creates a new one: two versions of the logic that will drift apart. The approach on this page keeps the workbook as the only copy of the logic and uses Python to feed it inputs, recalculate it and collect the answers. It is part of ",[14,15,17],"a",{"href":16},"\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002F","Working with Excel Formulas in Python",".",[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 openpyxl pandas\n# LibreOffice does the calculating\nsudo apt install libreoffice-calc\n","bash","",[32,33,34,53,60],"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}," openpyxl",[35,51,52],{"class":45}," pandas\n",[35,54,56],{"class":37,"line":55},2,[35,57,59],{"class":58},"s-wDw","# LibreOffice does the calculating\n",[35,61,63,66,69,71],{"class":37,"line":62},3,[35,64,65],{"class":41},"sudo",[35,67,68],{"class":45}," apt",[35,70,46],{"class":45},[35,72,73],{"class":45}," libreoffice-calc\n",[10,75,76,79,80,83,84,87],{},[32,77,78],{},"pricing_model.xlsx"," is a small product P&L. Five assumptions sit on the ",[32,81,82],{},"Inputs"," sheet — units sold, price, unit cost, fixed costs and tax rate — and the ",[32,85,86],{},"Model"," sheet turns them into revenue, contribution, operating and net profit, and a break-even volume. A real model would be larger; nothing below depends on its size.",[89,90,98,99,98,103,98,107,98,115,98,125,98,136,98,143,98,148,98,152,98,158,98,163,98,167,98,174,98,179,98,182,98,185,98,189,98,192,98,195,98,201,98,205,98,208,98,211,98,215,98,218,98,223,98,230,98,236,98,240,98,244,98,247,98,252,98,259,98,264,98,267],"svg",{"viewBox":91,"role":92,"ariaLabelledBy":93,"xmlns":96,"style":97},"4 0 752 142","img",[94,95],"wi-flow-t","wi-flow-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  ",[100,101,102],"title",{"id":94},"One model, many runs",[104,105,106],"desc",{"id":95},"Scenarios are defined in Python as input values, one copy of the model is written per scenario, LibreOffice recalculates all copies in a single call, and the outputs are read back into a comparison table.",[108,109],"rect",{"x":110,"y":111,"width":112,"height":113,"fill":114},"4","0","752","142","#ffffff",[108,116],{"x":117,"y":118,"width":119,"height":120,"rx":121,"fill":122,"stroke":123,"style":124},"20.0","16.0","115.2","110.5","12","#e7ebef","var(--line,#cdd5e6)","stroke-width:2px",[126,127,129],"g",{"transform":128},"translate(65.6,29.0) scale(1.000)",[130,131],"path",{"d":132,"fill":133,"stroke":134,"style":135},"M9 8l-4 4 4 4 M15 8l4 4-4 4","none","var(--text,#172033)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[137,138,142],"text",{"x":139,"y":140,"style":141},"77.6","75.5","font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","Scenarios",[137,144,147],{"x":139,"y":145,"style":146},"95.0","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","dict of input",[137,149,151],{"x":139,"y":150,"style":146},"112.5","values",[37,153],{"x1":154,"y1":155,"x2":156,"y2":155,"stroke":157,"style":124},"140.2","71.2","162.2","var(--brand,#5b5cf0)",[159,160],"polygon",{"points":161,"fill":162},"166.2,71.2 157.2,76.2 157.2,66.2","#5b5cf0",[108,164],{"x":165,"y":118,"width":119,"height":120,"rx":121,"fill":166,"stroke":157,"style":124},"171.2","#f0f4ff",[126,168,170],{"transform":169},"translate(216.8,29.0) scale(1.000)",[130,171],{"d":172,"fill":133,"stroke":173,"style":135},"M6 3h9l4 4v14H6z M15 3v4h4 M9.5 11.5l4.5 6 M14 11.5l-4.5 6","var(--brand-strong,#4338ca)",[137,175,178],{"x":176,"y":140,"style":177},"228.8","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","Write copies",[137,180,181],{"x":176,"y":145,"style":146},"openpyxl sets",[137,183,184],{"x":176,"y":150,"style":146},"the inputs",[37,186],{"x1":187,"y1":155,"x2":188,"y2":155,"stroke":157,"style":124},"291.4","313.4",[159,190],{"points":191,"fill":162},"317.4,71.2 308.4,76.2 308.4,66.2",[108,193],{"x":194,"y":118,"width":119,"height":120,"rx":121,"fill":166,"stroke":157,"style":124},"322.4",[126,196,198],{"transform":197},"translate(368.0,29.0) scale(1.000)",[130,199],{"d":200,"fill":133,"stroke":173,"style":135},"M20 11a8 8 0 1 0-2.3 5.7 M20 4v7h-7",[137,202,204],{"x":203,"y":140,"style":177},"380.0","Recalculate",[137,206,207],{"x":203,"y":145,"style":146},"one LibreOffice",[137,209,210],{"x":203,"y":150,"style":146},"call",[37,212],{"x1":213,"y1":155,"x2":214,"y2":155,"stroke":157,"style":124},"442.6","464.6",[159,216],{"points":217,"fill":162},"468.6,71.2 459.6,76.2 459.6,66.2",[108,219],{"x":220,"y":118,"width":119,"height":120,"rx":121,"fill":221,"stroke":222,"style":124},"473.6","#d9f4f1","var(--teal,#0f9488)",[126,224,226],{"transform":225},"translate(519.2,37.8) scale(1.000)",[130,227],{"d":228,"fill":133,"stroke":229,"style":135},"M11 4a7 7 0 1 0 .01 0z M16 16l5 5","var(--teal-ink,#0b6157)",[137,231,235],{"x":232,"y":233,"style":234},"531.2","84.2","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Read outputs",[137,237,239],{"x":232,"y":238,"style":146},"103.8","data_only=True",[37,241],{"x1":242,"y1":155,"x2":243,"y2":155,"stroke":157,"style":124},"593.8","615.8",[159,245],{"points":246,"fill":162},"619.8,71.2 610.8,76.2 610.8,66.2",[108,248],{"x":249,"y":118,"width":119,"height":120,"rx":121,"fill":250,"stroke":251,"style":124},"624.8","#fdefd8","var(--gold,#b4740a)",[126,253,255],{"transform":254},"translate(670.4,29.0) scale(1.000)",[130,256],{"d":257,"fill":133,"stroke":258,"style":135},"M4 20h16 M7 17v-5 M12 17V7 M17 17v-8","var(--gold-ink,#7a4e06)",[137,260,263],{"x":261,"y":140,"style":262},"682.4","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Compare",[137,265,266],{"x":261,"y":145,"style":146},"table +",[137,268,269],{"x":261,"y":150,"style":146},"sensitivity",[20,271,273],{"id":272},"step-1-name-the-inputs-and-outputs","Step 1 — Name the inputs and outputs",[10,275,276,277,280],{},"A script that writes to ",[32,278,279],{},"Inputs!B3"," breaks the day someone inserts a row. Describe the model's interface once — which cells are inputs, which are outputs — and refer to them by meaning everywhere else:",[25,282,286],{"className":283,"code":284,"language":285,"meta":30,"style":30},"language-python shiki shiki-themes github-light github-dark-high-contrast","INPUTS = {\"units\": \"Inputs!B2\", \"price\": \"Inputs!B3\", \"unit_cost\": \"Inputs!B4\",\n          \"fixed\": \"Inputs!B5\", \"tax\": \"Inputs!B6\"}\nOUTPUTS = {\"revenue\": \"Model!B2\", \"operating_profit\": \"Model!B5\",\n           \"net_profit\": \"Model!B7\", \"break_even_units\": \"Model!B8\"}\n\ndef split(ref):\n    sheet, cell = ref.split(\"!\")\n    return sheet, cell\n\nfrom openpyxl import load_workbook\nwb = load_workbook(\"pricing_model.xlsx\")\nbase = {k: wb[split(v)[0]][split(v)[1]].value for k, v in INPUTS.items()}\nprint(base)\n","python",[32,287,288,335,358,387,410,417,430,448,457,462,477,493,530],{"__ignoreMap":30},[35,289,290,294,298,302,305,308,311,314,317,319,322,324,327,329,332],{"class":37,"line":38},[35,291,293],{"class":292},"sP0c6","INPUTS",[35,295,297],{"class":296},"s-kum"," =",[35,299,301],{"class":300},"skGVy"," {",[35,303,304],{"class":45},"\"units\"",[35,306,307],{"class":300},": ",[35,309,310],{"class":45},"\"Inputs!B2\"",[35,312,313],{"class":300},", ",[35,315,316],{"class":45},"\"price\"",[35,318,307],{"class":300},[35,320,321],{"class":45},"\"Inputs!B3\"",[35,323,313],{"class":300},[35,325,326],{"class":45},"\"unit_cost\"",[35,328,307],{"class":300},[35,330,331],{"class":45},"\"Inputs!B4\"",[35,333,334],{"class":300},",\n",[35,336,337,340,342,345,347,350,352,355],{"class":37,"line":55},[35,338,339],{"class":45},"          \"fixed\"",[35,341,307],{"class":300},[35,343,344],{"class":45},"\"Inputs!B5\"",[35,346,313],{"class":300},[35,348,349],{"class":45},"\"tax\"",[35,351,307],{"class":300},[35,353,354],{"class":45},"\"Inputs!B6\"",[35,356,357],{"class":300},"}\n",[35,359,360,363,365,367,370,372,375,377,380,382,385],{"class":37,"line":62},[35,361,362],{"class":292},"OUTPUTS",[35,364,297],{"class":296},[35,366,301],{"class":300},[35,368,369],{"class":45},"\"revenue\"",[35,371,307],{"class":300},[35,373,374],{"class":45},"\"Model!B2\"",[35,376,313],{"class":300},[35,378,379],{"class":45},"\"operating_profit\"",[35,381,307],{"class":300},[35,383,384],{"class":45},"\"Model!B5\"",[35,386,334],{"class":300},[35,388,390,393,395,398,400,403,405,408],{"class":37,"line":389},4,[35,391,392],{"class":45},"           \"net_profit\"",[35,394,307],{"class":300},[35,396,397],{"class":45},"\"Model!B7\"",[35,399,313],{"class":300},[35,401,402],{"class":45},"\"break_even_units\"",[35,404,307],{"class":300},[35,406,407],{"class":45},"\"Model!B8\"",[35,409,357],{"class":300},[35,411,413],{"class":37,"line":412},5,[35,414,416],{"emptyLinePlaceholder":415},true,"\n",[35,418,420,423,427],{"class":37,"line":419},6,[35,421,422],{"class":296},"def",[35,424,426],{"class":425},"s_Opv"," split",[35,428,429],{"class":300},"(ref):\n",[35,431,433,436,439,442,445],{"class":37,"line":432},7,[35,434,435],{"class":300},"    sheet, cell ",[35,437,438],{"class":296},"=",[35,440,441],{"class":300}," ref.split(",[35,443,444],{"class":45},"\"!\"",[35,446,447],{"class":300},")\n",[35,449,451,454],{"class":37,"line":450},8,[35,452,453],{"class":296},"    return",[35,455,456],{"class":300}," sheet, cell\n",[35,458,460],{"class":37,"line":459},9,[35,461,416],{"emptyLinePlaceholder":415},[35,463,465,468,471,474],{"class":37,"line":464},10,[35,466,467],{"class":296},"from",[35,469,470],{"class":300}," openpyxl ",[35,472,473],{"class":296},"import",[35,475,476],{"class":300}," load_workbook\n",[35,478,480,483,485,488,491],{"class":37,"line":479},11,[35,481,482],{"class":300},"wb ",[35,484,438],{"class":296},[35,486,487],{"class":300}," load_workbook(",[35,489,490],{"class":45},"\"pricing_model.xlsx\"",[35,492,447],{"class":300},[35,494,496,499,501,504,506,509,512,515,518,521,524,527],{"class":37,"line":495},12,[35,497,498],{"class":300},"base ",[35,500,438],{"class":296},[35,502,503],{"class":300}," {k: wb[split(v)[",[35,505,111],{"class":292},[35,507,508],{"class":300},"]][split(v)[",[35,510,511],{"class":292},"1",[35,513,514],{"class":300},"]].value ",[35,516,517],{"class":296},"for",[35,519,520],{"class":300}," k, v ",[35,522,523],{"class":296},"in",[35,525,526],{"class":292}," INPUTS",[35,528,529],{"class":300},".items()}\n",[35,531,533,536],{"class":37,"line":532},13,[35,534,535],{"class":292},"print",[35,537,538],{"class":300},"(base)\n",[10,540,541,542,313,545,548,549,553],{},"If the model already has defined names for its inputs — ",[32,543,544],{},"Price",[32,546,547],{},"UnitCost"," — use those instead of addresses, resolving them as in ",[14,550,552],{"href":551},"\u002Fgetting-started-with-python-excel-automation\u002Freading-excel-files-with-pandas\u002Fread-a-named-range-into-pandas\u002F","Read a Named Range into pandas",". Either way the mapping is the contract between the model's owner and the script, and it is short enough to review.",[20,555,557],{"id":556},"step-2-define-the-scenarios-and-write-one-copy-each","Step 2 — Define the scenarios and write one copy each",[10,559,560],{},"A scenario is a set of overrides on the base inputs. Write a copy of the model for each, changing only the input cells:",[25,562,564],{"className":283,"code":563,"language":285,"meta":30,"style":30},"from pathlib import Path\n\nSCENARIOS = {\n    \"base\": {},\n    \"upside\": {\"units\": 12000, \"price\": 25},\n    \"downside\": {\"units\": 8500, \"unit_cost\": 15.5},\n    \"price_cut\": {\"price\": 21.5, \"units\": 12500},\n}\n\nruns = Path(\"runs\")\nruns.mkdir(exist_ok=True)\nfor name, overrides in SCENARIOS.items():\n    wb = load_workbook(\"pricing_model.xlsx\")\n    for key, value in {**base, **overrides}.items():\n        sheet, cell = split(INPUTS[key])\n        wb[sheet][cell] = value\n    wb.save(runs \u002F f\"{name}.xlsx\")\nprint(sorted(p.name for p in runs.iterdir()))\n",[32,565,566,578,582,592,600,627,652,677,681,685,700,716,731,744,768,784,795,825],{"__ignoreMap":30},[35,567,568,570,573,575],{"class":37,"line":38},[35,569,467],{"class":296},[35,571,572],{"class":300}," pathlib ",[35,574,473],{"class":296},[35,576,577],{"class":300}," Path\n",[35,579,580],{"class":37,"line":55},[35,581,416],{"emptyLinePlaceholder":415},[35,583,584,587,589],{"class":37,"line":62},[35,585,586],{"class":292},"SCENARIOS",[35,588,297],{"class":296},[35,590,591],{"class":300}," {\n",[35,593,594,597],{"class":37,"line":389},[35,595,596],{"class":45},"    \"base\"",[35,598,599],{"class":300},": {},\n",[35,601,602,605,608,610,612,615,617,619,621,624],{"class":37,"line":412},[35,603,604],{"class":45},"    \"upside\"",[35,606,607],{"class":300},": {",[35,609,304],{"class":45},[35,611,307],{"class":300},[35,613,614],{"class":292},"12000",[35,616,313],{"class":300},[35,618,316],{"class":45},[35,620,307],{"class":300},[35,622,623],{"class":292},"25",[35,625,626],{"class":300},"},\n",[35,628,629,632,634,636,638,641,643,645,647,650],{"class":37,"line":419},[35,630,631],{"class":45},"    \"downside\"",[35,633,607],{"class":300},[35,635,304],{"class":45},[35,637,307],{"class":300},[35,639,640],{"class":292},"8500",[35,642,313],{"class":300},[35,644,326],{"class":45},[35,646,307],{"class":300},[35,648,649],{"class":292},"15.5",[35,651,626],{"class":300},[35,653,654,657,659,661,663,666,668,670,672,675],{"class":37,"line":432},[35,655,656],{"class":45},"    \"price_cut\"",[35,658,607],{"class":300},[35,660,316],{"class":45},[35,662,307],{"class":300},[35,664,665],{"class":292},"21.5",[35,667,313],{"class":300},[35,669,304],{"class":45},[35,671,307],{"class":300},[35,673,674],{"class":292},"12500",[35,676,626],{"class":300},[35,678,679],{"class":37,"line":450},[35,680,357],{"class":300},[35,682,683],{"class":37,"line":459},[35,684,416],{"emptyLinePlaceholder":415},[35,686,687,690,692,695,698],{"class":37,"line":464},[35,688,689],{"class":300},"runs ",[35,691,438],{"class":296},[35,693,694],{"class":300}," Path(",[35,696,697],{"class":45},"\"runs\"",[35,699,447],{"class":300},[35,701,702,705,709,711,714],{"class":37,"line":479},[35,703,704],{"class":300},"runs.mkdir(",[35,706,708],{"class":707},"sa561","exist_ok",[35,710,438],{"class":296},[35,712,713],{"class":292},"True",[35,715,447],{"class":300},[35,717,718,720,723,725,728],{"class":37,"line":495},[35,719,517],{"class":296},[35,721,722],{"class":300}," name, overrides ",[35,724,523],{"class":296},[35,726,727],{"class":292}," SCENARIOS",[35,729,730],{"class":300},".items():\n",[35,732,733,736,738,740,742],{"class":37,"line":532},[35,734,735],{"class":300},"    wb ",[35,737,438],{"class":296},[35,739,487],{"class":300},[35,741,490],{"class":45},[35,743,447],{"class":300},[35,745,747,750,753,755,757,760,763,765],{"class":37,"line":746},14,[35,748,749],{"class":296},"    for",[35,751,752],{"class":300}," key, value ",[35,754,523],{"class":296},[35,756,301],{"class":300},[35,758,759],{"class":296},"**",[35,761,762],{"class":300},"base, ",[35,764,759],{"class":296},[35,766,767],{"class":300},"overrides}.items():\n",[35,769,771,774,776,779,781],{"class":37,"line":770},15,[35,772,773],{"class":300},"        sheet, cell ",[35,775,438],{"class":296},[35,777,778],{"class":300}," split(",[35,780,293],{"class":292},[35,782,783],{"class":300},"[key])\n",[35,785,787,790,792],{"class":37,"line":786},16,[35,788,789],{"class":300},"        wb[sheet][cell] ",[35,791,438],{"class":296},[35,793,794],{"class":300}," value\n",[35,796,798,801,804,807,810,814,817,820,823],{"class":37,"line":797},17,[35,799,800],{"class":300},"    wb.save(runs ",[35,802,803],{"class":296},"\u002F",[35,805,806],{"class":296}," f",[35,808,809],{"class":45},"\"",[35,811,813],{"class":812},"sSjpA","{",[35,815,816],{"class":300},"name",[35,818,819],{"class":812},"}",[35,821,822],{"class":45},".xlsx\"",[35,824,447],{"class":300},[35,826,828,830,833,836,839,841,844,846],{"class":37,"line":827},18,[35,829,535],{"class":292},[35,831,832],{"class":300},"(",[35,834,835],{"class":292},"sorted",[35,837,838],{"class":300},"(p.name ",[35,840,517],{"class":296},[35,842,843],{"class":300}," p ",[35,845,523],{"class":296},[35,847,848],{"class":300}," runs.iterdir()))\n",[10,850,851,852,855,856,859,860,863],{},"Unknown keys fail loudly (",[32,853,854],{},"KeyError"," on ",[32,857,858],{},"INPUTS[key]","), which catches typos such as ",[32,861,862],{},"unit_cots"," before they produce a silently wrong run. openpyxl keeps the formulas as they are and writes no cached results, so each copy is a model waiting to be calculated.",[20,865,867],{"id":866},"step-3-recalculate-every-copy-in-one-call","Step 3 — Recalculate every copy in one call",[10,869,870],{},"openpyxl cannot calculate formulas. Headless LibreOffice can, and it accepts many files at once, so one process start covers every scenario:",[25,872,874],{"className":27,"code":873,"language":29,"meta":30,"style":30},"soffice -env:UserInstallation=file:\u002F\u002F$PWD\u002Flo_profile --headless --convert-to xlsx --outdir calculated runs\u002F*.xlsx > \u002Fdev\u002Fnull 2>&1\nls calculated\n",[32,875,876,923],{"__ignoreMap":30},[35,877,878,881,884,887,890,893,896,899,902,905,908,911,914,917,920],{"class":37,"line":38},[35,879,880],{"class":41},"soffice",[35,882,883],{"class":292}," -env:UserInstallation=file:\u002F\u002F",[35,885,886],{"class":300},"$PWD",[35,888,889],{"class":292},"\u002Flo_profile",[35,891,892],{"class":292}," --headless",[35,894,895],{"class":292}," --convert-to",[35,897,898],{"class":45}," xlsx",[35,900,901],{"class":292}," --outdir",[35,903,904],{"class":45}," calculated",[35,906,907],{"class":45}," runs\u002F",[35,909,910],{"class":292},"*",[35,912,913],{"class":45},".xlsx",[35,915,916],{"class":296}," >",[35,918,919],{"class":45}," \u002Fdev\u002Fnull",[35,921,922],{"class":296}," 2>&1\n",[35,924,925,928],{"class":37,"line":55},[35,926,927],{"class":41},"ls",[35,929,930],{"class":45}," calculated\n",[10,932,933,934,936,937,940,941,945],{},"LibreOffice opens each file, calculates the formulas (the copies have no cached values, so it has to), and saves a new ",[32,935,913],{}," that contains both the formulas and their results. Starting LibreOffice is the slow part — around a second — so batching all scenarios into one call matters more than anything else for speed. The private ",[32,938,939],{},"-env:UserInstallation"," profile keeps this from clashing with any other LibreOffice process on the machine. ",[14,942,944],{"href":943},"\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Frecalculate-excel-formulas-without-excel-in-python\u002F","Recalculate Excel Formulas Without Excel"," discusses where LibreOffice's results can differ from Excel's.",[20,947,949],{"id":948},"step-4-read-the-outputs-into-a-table","Step 4 — Read the outputs into a table",[10,951,952,953,955],{},"Open each calculated copy with ",[32,954,239],{},", which returns the stored results instead of the formulas, and collect the outputs:",[25,957,959],{"className":283,"code":958,"language":285,"meta":30,"style":30},"import pandas as pd\n\ndef read_outputs(path):\n    wb = load_workbook(path, data_only=True)\n    return {k: wb[split(v)[0]][split(v)[1]].value for k, v in OUTPUTS.items()}\n\nresults = pd.DataFrame({name: read_outputs(Path(\"calculated\") \u002F f\"{name}.xlsx\") for name in SCENARIOS}).T\nprint(results.round(1))\n",[32,960,961,974,978,988,1006,1031,1035,1079],{"__ignoreMap":30},[35,962,963,965,968,971],{"class":37,"line":38},[35,964,473],{"class":296},[35,966,967],{"class":300}," pandas ",[35,969,970],{"class":296},"as",[35,972,973],{"class":300}," pd\n",[35,975,976],{"class":37,"line":55},[35,977,416],{"emptyLinePlaceholder":415},[35,979,980,982,985],{"class":37,"line":62},[35,981,422],{"class":296},[35,983,984],{"class":425}," read_outputs",[35,986,987],{"class":300},"(path):\n",[35,989,990,992,994,997,1000,1002,1004],{"class":37,"line":389},[35,991,735],{"class":300},[35,993,438],{"class":296},[35,995,996],{"class":300}," load_workbook(path, ",[35,998,999],{"class":707},"data_only",[35,1001,438],{"class":296},[35,1003,713],{"class":292},[35,1005,447],{"class":300},[35,1007,1008,1010,1012,1014,1016,1018,1020,1022,1024,1026,1029],{"class":37,"line":412},[35,1009,453],{"class":296},[35,1011,503],{"class":300},[35,1013,111],{"class":292},[35,1015,508],{"class":300},[35,1017,511],{"class":292},[35,1019,514],{"class":300},[35,1021,517],{"class":296},[35,1023,520],{"class":300},[35,1025,523],{"class":296},[35,1027,1028],{"class":292}," OUTPUTS",[35,1030,529],{"class":300},[35,1032,1033],{"class":37,"line":419},[35,1034,416],{"emptyLinePlaceholder":415},[35,1036,1037,1040,1042,1045,1048,1051,1053,1055,1057,1059,1061,1063,1065,1067,1069,1072,1074,1076],{"class":37,"line":432},[35,1038,1039],{"class":300},"results ",[35,1041,438],{"class":296},[35,1043,1044],{"class":300}," pd.DataFrame({name: read_outputs(Path(",[35,1046,1047],{"class":45},"\"calculated\"",[35,1049,1050],{"class":300},") ",[35,1052,803],{"class":296},[35,1054,806],{"class":296},[35,1056,809],{"class":45},[35,1058,813],{"class":812},[35,1060,816],{"class":300},[35,1062,819],{"class":812},[35,1064,822],{"class":45},[35,1066,1050],{"class":300},[35,1068,517],{"class":296},[35,1070,1071],{"class":300}," name ",[35,1073,523],{"class":296},[35,1075,727],{"class":292},[35,1077,1078],{"class":300},"}).T\n",[35,1080,1081,1083,1086,1088],{"class":37,"line":450},[35,1082,535],{"class":292},[35,1084,1085],{"class":300},"(results.round(",[35,1087,511],{"class":292},[35,1089,1090],{"class":300},"))\n",[10,1092,1093],{},"The price cut sells 2,500 more units than the base case and still earns less: at €21.50 the contribution per unit falls from €9.50 to €7, and the extra volume does not make up for it. Its break-even volume rises from about 6,300 units to 8,600. That is the kind of result a scenario table exists to make obvious.",[89,1095,98,1101,98,1104,98,1107,98,1111,98,1117,98,1122,98,1126,98,1130,98,1133,98,1137,98,1140,98,1144,98,1148,98,1153,98,1157,98,1161,98,1165,98,1172,98,1177,98,1181,98,1185,98,1190,98,1194,98,1199],{"viewBox":1096,"role":92,"ariaLabelledBy":1097,"xmlns":96,"style":1100},"4 0 748 232",[1098,1099],"wi-scen-t","wi-scen-d","width:100%;max-width:748px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[100,1102,1103],{"id":1098},"Net profit by scenario",[104,1105,1106],{"id":1099},"Column chart of net profit in each scenario — base about 26 thousand, upside about 50 thousand, downside about 9 thousand, and the price cut about 21 thousand.",[108,1108],{"x":110,"y":111,"width":1109,"height":1110,"fill":114},"748","232",[37,1112],{"x1":1113,"y1":1114,"x2":1115,"y2":1114,"stroke":123,"style":1116},"46.7","194.0","736.0","stroke-width:1px",[137,1118,111],{"x":1119,"y":1120,"style":1121},"27.4","198.0","font-size:11px;font-weight:400;fill:var(--muted,#5b6780);text-anchor:middle",[37,1123],{"x1":1113,"y1":1124,"x2":1115,"y2":1124,"stroke":123,"style":1125},"137.3","stroke-width:1px;stroke-dasharray:5 4",[137,1127,1129],{"x":1119,"y":1128,"style":1121},"141.3","20",[37,1131],{"x1":1113,"y1":1132,"x2":1115,"y2":1132,"stroke":123,"style":1125},"80.7",[137,1134,1136],{"x":1119,"y":1135,"style":1121},"84.7","40",[37,1138],{"x1":1113,"y1":1139,"x2":1115,"y2":1139,"stroke":123,"style":1125},"24.0",[137,1141,1143],{"x":1119,"y":1142,"style":1121},"28.0","60",[37,1145],{"x1":1113,"y1":1114,"x2":1115,"y2":1114,"stroke":1146,"style":1147},"var(--muted,#5b6780)","stroke-width:1.5px",[137,1149,1152],{"x":1150,"y":1151,"style":146},"132.9","212.0","base",[137,1154,1156],{"x":1155,"y":1151,"style":146},"305.2","upside",[137,1158,1160],{"x":1159,"y":1151,"style":146},"477.5","downside",[137,1162,1164],{"x":1163,"y":1151,"style":146},"649.8","price_cut",[108,1166],{"x":1167,"y":1168,"width":1169,"height":1170,"rx":1171,"fill":166,"stroke":157,"style":1147},"114.9","119.5","36.0","74.5","3",[137,1173,1176],{"x":1150,"y":1174,"style":1175},"113.5","font-size:10.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","26.3",[108,1178],{"x":1179,"y":1180,"width":1169,"height":154,"rx":1171,"fill":166,"stroke":157,"style":1147},"287.2","53.8",[137,1182,1184],{"x":1155,"y":1183,"style":1175},"47.8","49.5",[108,1186],{"x":1187,"y":1188,"width":1169,"height":1189,"rx":1171,"fill":166,"stroke":157,"style":1147},"459.5","167.9","26.1",[137,1191,1193],{"x":1159,"y":1192,"style":1175},"161.9","9.2",[108,1195],{"x":1196,"y":1197,"width":1169,"height":1198,"rx":1171,"fill":166,"stroke":157,"style":1147},"631.8","135.6","58.4",[137,1200,1202],{"x":1163,"y":1201,"style":1175},"129.6","20.6",[20,1204,1206],{"id":1205},"step-5-sensitivity-nudge-every-input","Step 5 — Sensitivity: nudge every input",[10,1208,1209],{},"A sensitivity run changes one input at a time by the same percentage and records how far the result moves. It shows which assumptions deserve the most scrutiny. Generate the runs, recalculate them together, and compare:",[25,1211,1213],{"className":283,"code":1212,"language":285,"meta":30,"style":30},"sens = {}\nfor key in INPUTS:\n    for sign, factor in ((\"-\", 0.9), (\"+\", 1.1)):\n        sens[f\"{key}{sign}\"] = {key: base[key] * factor}\n\nfor name, overrides in sens.items():\n    wb = load_workbook(\"pricing_model.xlsx\")\n    for k, v in overrides.items():\n        sheet, cell = split(INPUTS[k])\n        wb[sheet][cell] = v\n    wb.save(runs \u002F f\"sens_{name}.xlsx\")\n",[32,1214,1215,1225,1239,1273,1311,1315,1326,1338,1349,1362,1371],{"__ignoreMap":30},[35,1216,1217,1220,1222],{"class":37,"line":38},[35,1218,1219],{"class":300},"sens ",[35,1221,438],{"class":296},[35,1223,1224],{"class":300}," {}\n",[35,1226,1227,1229,1232,1234,1236],{"class":37,"line":55},[35,1228,517],{"class":296},[35,1230,1231],{"class":300}," key ",[35,1233,523],{"class":296},[35,1235,526],{"class":292},[35,1237,1238],{"class":300},":\n",[35,1240,1241,1243,1246,1248,1251,1254,1256,1259,1262,1265,1267,1270],{"class":37,"line":62},[35,1242,749],{"class":296},[35,1244,1245],{"class":300}," sign, factor ",[35,1247,523],{"class":296},[35,1249,1250],{"class":300}," ((",[35,1252,1253],{"class":45},"\"-\"",[35,1255,313],{"class":300},[35,1257,1258],{"class":292},"0.9",[35,1260,1261],{"class":300},"), (",[35,1263,1264],{"class":45},"\"+\"",[35,1266,313],{"class":300},[35,1268,1269],{"class":292},"1.1",[35,1271,1272],{"class":300},")):\n",[35,1274,1275,1278,1281,1283,1285,1288,1291,1294,1296,1298,1301,1303,1306,1308],{"class":37,"line":389},[35,1276,1277],{"class":300},"        sens[",[35,1279,1280],{"class":296},"f",[35,1282,809],{"class":45},[35,1284,813],{"class":812},[35,1286,1287],{"class":300},"key",[35,1289,1290],{"class":812},"}{",[35,1292,1293],{"class":300},"sign",[35,1295,819],{"class":812},[35,1297,809],{"class":45},[35,1299,1300],{"class":300},"] ",[35,1302,438],{"class":296},[35,1304,1305],{"class":300}," {key: base[key] ",[35,1307,910],{"class":296},[35,1309,1310],{"class":300}," factor}\n",[35,1312,1313],{"class":37,"line":412},[35,1314,416],{"emptyLinePlaceholder":415},[35,1316,1317,1319,1321,1323],{"class":37,"line":419},[35,1318,517],{"class":296},[35,1320,722],{"class":300},[35,1322,523],{"class":296},[35,1324,1325],{"class":300}," sens.items():\n",[35,1327,1328,1330,1332,1334,1336],{"class":37,"line":432},[35,1329,735],{"class":300},[35,1331,438],{"class":296},[35,1333,487],{"class":300},[35,1335,490],{"class":45},[35,1337,447],{"class":300},[35,1339,1340,1342,1344,1346],{"class":37,"line":450},[35,1341,749],{"class":296},[35,1343,520],{"class":300},[35,1345,523],{"class":296},[35,1347,1348],{"class":300}," overrides.items():\n",[35,1350,1351,1353,1355,1357,1359],{"class":37,"line":459},[35,1352,773],{"class":300},[35,1354,438],{"class":296},[35,1356,778],{"class":300},[35,1358,293],{"class":292},[35,1360,1361],{"class":300},"[k])\n",[35,1363,1364,1366,1368],{"class":37,"line":464},[35,1365,789],{"class":300},[35,1367,438],{"class":296},[35,1369,1370],{"class":300}," v\n",[35,1372,1373,1375,1377,1379,1382,1384,1386,1388,1390],{"class":37,"line":479},[35,1374,800],{"class":300},[35,1376,803],{"class":296},[35,1378,806],{"class":296},[35,1380,1381],{"class":45},"\"sens_",[35,1383,813],{"class":812},[35,1385,816],{"class":300},[35,1387,819],{"class":812},[35,1389,822],{"class":45},[35,1391,447],{"class":300},[25,1393,1395],{"className":27,"code":1394,"language":29,"meta":30,"style":30},"soffice -env:UserInstallation=file:\u002F\u002F$PWD\u002Flo_profile --headless --convert-to xlsx --outdir calculated runs\u002Fsens_*.xlsx > \u002Fdev\u002Fnull 2>&1\nls calculated | wc -l\n",[32,1396,1397,1430],{"__ignoreMap":30},[35,1398,1399,1401,1403,1405,1407,1409,1411,1413,1415,1417,1420,1422,1424,1426,1428],{"class":37,"line":38},[35,1400,880],{"class":41},[35,1402,883],{"class":292},[35,1404,886],{"class":300},[35,1406,889],{"class":292},[35,1408,892],{"class":292},[35,1410,895],{"class":292},[35,1412,898],{"class":45},[35,1414,901],{"class":292},[35,1416,904],{"class":45},[35,1418,1419],{"class":45}," runs\u002Fsens_",[35,1421,910],{"class":292},[35,1423,913],{"class":45},[35,1425,916],{"class":296},[35,1427,919],{"class":45},[35,1429,922],{"class":296},[35,1431,1432,1434,1436,1439,1442],{"class":37,"line":55},[35,1433,927],{"class":41},[35,1435,904],{"class":45},[35,1437,1438],{"class":296}," |",[35,1440,1441],{"class":41}," wc",[35,1443,1444],{"class":292}," -l\n",[25,1446,1448],{"className":283,"code":1447,"language":285,"meta":30,"style":30},"base_net = results.loc[\"base\", \"net_profit\"]\nrows = []\nfor key in INPUTS:\n    low = read_outputs(Path(\"calculated\") \u002F f\"sens_{key}-.xlsx\")[\"net_profit\"] - base_net\n    high = read_outputs(Path(\"calculated\") \u002F f\"sens_{key}+.xlsx\")[\"net_profit\"] - base_net\n    rows.append({\"input\": key, \"-10%\": round(low), \"+10%\": round(high), \"swing\": round(abs(high - low))})\ntornado = pd.DataFrame(rows).sort_values(\"swing\", ascending=False)\nprint(tornado.to_string(index=False))\n",[32,1449,1450,1471,1481,1493,1535,1573,1625,1649],{"__ignoreMap":30},[35,1451,1452,1455,1457,1460,1463,1465,1468],{"class":37,"line":38},[35,1453,1454],{"class":300},"base_net ",[35,1456,438],{"class":296},[35,1458,1459],{"class":300}," results.loc[",[35,1461,1462],{"class":45},"\"base\"",[35,1464,313],{"class":300},[35,1466,1467],{"class":45},"\"net_profit\"",[35,1469,1470],{"class":300},"]\n",[35,1472,1473,1476,1478],{"class":37,"line":55},[35,1474,1475],{"class":300},"rows ",[35,1477,438],{"class":296},[35,1479,1480],{"class":300}," []\n",[35,1482,1483,1485,1487,1489,1491],{"class":37,"line":62},[35,1484,517],{"class":296},[35,1486,1231],{"class":300},[35,1488,523],{"class":296},[35,1490,526],{"class":292},[35,1492,1238],{"class":300},[35,1494,1495,1498,1500,1503,1505,1507,1509,1511,1513,1515,1517,1519,1522,1525,1527,1529,1532],{"class":37,"line":389},[35,1496,1497],{"class":300},"    low ",[35,1499,438],{"class":296},[35,1501,1502],{"class":300}," read_outputs(Path(",[35,1504,1047],{"class":45},[35,1506,1050],{"class":300},[35,1508,803],{"class":296},[35,1510,806],{"class":296},[35,1512,1381],{"class":45},[35,1514,813],{"class":812},[35,1516,1287],{"class":300},[35,1518,819],{"class":812},[35,1520,1521],{"class":45},"-.xlsx\"",[35,1523,1524],{"class":300},")[",[35,1526,1467],{"class":45},[35,1528,1300],{"class":300},[35,1530,1531],{"class":296},"-",[35,1533,1534],{"class":300}," base_net\n",[35,1536,1537,1540,1542,1544,1546,1548,1550,1552,1554,1556,1558,1560,1563,1565,1567,1569,1571],{"class":37,"line":412},[35,1538,1539],{"class":300},"    high ",[35,1541,438],{"class":296},[35,1543,1502],{"class":300},[35,1545,1047],{"class":45},[35,1547,1050],{"class":300},[35,1549,803],{"class":296},[35,1551,806],{"class":296},[35,1553,1381],{"class":45},[35,1555,813],{"class":812},[35,1557,1287],{"class":300},[35,1559,819],{"class":812},[35,1561,1562],{"class":45},"+.xlsx\"",[35,1564,1524],{"class":300},[35,1566,1467],{"class":45},[35,1568,1300],{"class":300},[35,1570,1531],{"class":296},[35,1572,1534],{"class":300},[35,1574,1575,1578,1581,1584,1587,1589,1592,1595,1598,1600,1602,1605,1608,1610,1612,1614,1617,1620,1622],{"class":37,"line":419},[35,1576,1577],{"class":300},"    rows.append({",[35,1579,1580],{"class":45},"\"input\"",[35,1582,1583],{"class":300},": key, ",[35,1585,1586],{"class":45},"\"-10%\"",[35,1588,307],{"class":300},[35,1590,1591],{"class":292},"round",[35,1593,1594],{"class":300},"(low), ",[35,1596,1597],{"class":45},"\"+10%\"",[35,1599,307],{"class":300},[35,1601,1591],{"class":292},[35,1603,1604],{"class":300},"(high), ",[35,1606,1607],{"class":45},"\"swing\"",[35,1609,307],{"class":300},[35,1611,1591],{"class":292},[35,1613,832],{"class":300},[35,1615,1616],{"class":292},"abs",[35,1618,1619],{"class":300},"(high ",[35,1621,1531],{"class":296},[35,1623,1624],{"class":300}," low))})\n",[35,1626,1627,1630,1632,1635,1637,1639,1642,1644,1647],{"class":37,"line":432},[35,1628,1629],{"class":300},"tornado ",[35,1631,438],{"class":296},[35,1633,1634],{"class":300}," pd.DataFrame(rows).sort_values(",[35,1636,1607],{"class":45},[35,1638,313],{"class":300},[35,1640,1641],{"class":707},"ascending",[35,1643,438],{"class":296},[35,1645,1646],{"class":292},"False",[35,1648,447],{"class":300},[35,1650,1651,1653,1656,1659,1661,1663],{"class":37,"line":450},[35,1652,535],{"class":292},[35,1654,1655],{"class":300},"(tornado.to_string(",[35,1657,1658],{"class":707},"index",[35,1660,438],{"class":296},[35,1662,1646],{"class":292},[35,1664,1090],{"class":300},[10,1666,1667],{},"Sorted by swing, this is the data behind a tornado chart. Price dominates: a 10% move in price shifts net profit by €18,000 either way, more than any other assumption, followed by unit cost and volume. The tax rate barely matters at this level of profit. If the pricing assumption is the least certain one, it is also the one most worth getting right.",[89,1669,98,1675,98,1678,98,1681,98,1685,98,1689,98,1695,98,1699,98,1704,98,1707,98,1710,98,1713,98,1717,98,1720,98,1724,98,1727,98,1732,98,1735,98,1739,98,1742,98,1746,98,1749,98,1753,98,1755],{"viewBox":1670,"role":92,"ariaLabelledBy":1671,"xmlns":96,"style":1674},"4 0 744 220",[1672,1673],"wi-tornado-t","wi-tornado-d","width:100%;max-width:744px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif",[100,1676,1677],{"id":1672},"Swing in net profit from a ±10% change in each input",[104,1679,1680],{"id":1673},"Horizontal bars ranking the inputs by how much a ten percent change moves net profit — price 36,000, unit cost 21,750, units 14,250, fixed costs 9,000, tax rate 1,750.",[108,1682],{"x":110,"y":111,"width":1683,"height":1684,"fill":114},"744","220",[108,1686],{"x":117,"y":118,"width":1687,"height":1142,"rx":1688,"fill":122,"stroke":123,"style":1116},"98.9","7",[137,1690,1694],{"x":1691,"y":1692,"style":1693},"69.4","34.5","font-size:12.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","price",[108,1696],{"x":1697,"y":118,"width":1698,"height":1142,"rx":1688,"fill":166,"stroke":157,"style":1147},"128.9","540.5",[137,1700,1703],{"x":1701,"y":1692,"style":1702},"677.4","font-size:12px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:start","€36,000",[108,1705],{"x":117,"y":1706,"width":1687,"height":1142,"rx":1688,"fill":122,"stroke":123,"style":1116},"56.0",[137,1708,1709],{"x":1691,"y":1170,"style":1693},"unit cost",[108,1711],{"x":1697,"y":1706,"width":1712,"height":1142,"rx":1688,"fill":166,"stroke":157,"style":1147},"326.6",[137,1714,1716],{"x":1715,"y":1170,"style":1702},"463.4","€21,750",[108,1718],{"x":117,"y":1719,"width":1687,"height":1142,"rx":1688,"fill":122,"stroke":123,"style":1116},"96.0",[137,1721,1723],{"x":1691,"y":1722,"style":1693},"114.5","units sold",[108,1725],{"x":1697,"y":1719,"width":1726,"height":1142,"rx":1688,"fill":221,"stroke":222,"style":1147},"213.9",[137,1728,1731],{"x":1729,"y":1722,"style":1730},"350.8","font-size:12px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:start","€14,250",[108,1733],{"x":117,"y":1734,"width":1687,"height":1142,"rx":1688,"fill":122,"stroke":123,"style":1116},"136.0",[137,1736,1738],{"x":1691,"y":1737,"style":1693},"154.5","fixed costs",[108,1740],{"x":1697,"y":1734,"width":1741,"height":1142,"rx":1688,"fill":221,"stroke":222,"style":1147},"135.1",[137,1743,1745],{"x":1744,"y":1737,"style":1730},"272.0","€9,000",[108,1747],{"x":117,"y":1748,"width":1687,"height":1142,"rx":1688,"fill":122,"stroke":123,"style":1116},"176.0",[137,1750,1752],{"x":1691,"y":1751,"style":1693},"194.5","tax rate",[108,1754],{"x":1697,"y":1748,"width":1176,"height":1142,"rx":1688,"fill":122,"stroke":123,"style":1147},[137,1756,1759],{"x":1757,"y":1751,"style":1758},"163.1","font-size:12px;font-weight:700;fill:var(--text,#172033);text-anchor:start","€1,750",[20,1761,1763],{"id":1762},"step-6-write-the-comparison-workbook","Step 6 — Write the comparison workbook",[10,1765,1766],{},"Put the scenario table and the sensitivity table in a workbook for the people who own the model:",[25,1768,1770],{"className":283,"code":1769,"language":285,"meta":30,"style":30},"with pd.ExcelWriter(\"scenario_comparison.xlsx\", engine=\"xlsxwriter\") as xw:\n    table = pd.DataFrame({n: {**base, **o} for n, o in SCENARIOS.items()}).T.join(results)\n    table.to_excel(xw, sheet_name=\"Scenarios\")\n    tornado.to_excel(xw, sheet_name=\"Sensitivity\", index=False)\n    money = xw.book.add_format({\"num_format\": \"#,##0\"})\n    xw.sheets[\"Scenarios\"].set_column(\"B:K\", 14, money)\n    xw.sheets[\"Sensitivity\"].set_column(\"A:D\", 12, money)\nprint(table[[\"units\", \"price\", \"net_profit\"]].round(2))\n",[32,1771,1772,1800,1831,1846,1868,1889,1910,1927],{"__ignoreMap":30},[35,1773,1774,1777,1780,1783,1785,1788,1790,1793,1795,1797],{"class":37,"line":38},[35,1775,1776],{"class":296},"with",[35,1778,1779],{"class":300}," pd.ExcelWriter(",[35,1781,1782],{"class":45},"\"scenario_comparison.xlsx\"",[35,1784,313],{"class":300},[35,1786,1787],{"class":707},"engine",[35,1789,438],{"class":296},[35,1791,1792],{"class":45},"\"xlsxwriter\"",[35,1794,1050],{"class":300},[35,1796,970],{"class":296},[35,1798,1799],{"class":300}," xw:\n",[35,1801,1802,1805,1807,1810,1812,1814,1816,1819,1821,1824,1826,1828],{"class":37,"line":55},[35,1803,1804],{"class":300},"    table ",[35,1806,438],{"class":296},[35,1808,1809],{"class":300}," pd.DataFrame({n: {",[35,1811,759],{"class":296},[35,1813,762],{"class":300},[35,1815,759],{"class":296},[35,1817,1818],{"class":300},"o} ",[35,1820,517],{"class":296},[35,1822,1823],{"class":300}," n, o ",[35,1825,523],{"class":296},[35,1827,727],{"class":292},[35,1829,1830],{"class":300},".items()}).T.join(results)\n",[35,1832,1833,1836,1839,1841,1844],{"class":37,"line":62},[35,1834,1835],{"class":300},"    table.to_excel(xw, ",[35,1837,1838],{"class":707},"sheet_name",[35,1840,438],{"class":296},[35,1842,1843],{"class":45},"\"Scenarios\"",[35,1845,447],{"class":300},[35,1847,1848,1851,1853,1855,1858,1860,1862,1864,1866],{"class":37,"line":389},[35,1849,1850],{"class":300},"    tornado.to_excel(xw, ",[35,1852,1838],{"class":707},[35,1854,438],{"class":296},[35,1856,1857],{"class":45},"\"Sensitivity\"",[35,1859,313],{"class":300},[35,1861,1658],{"class":707},[35,1863,438],{"class":296},[35,1865,1646],{"class":292},[35,1867,447],{"class":300},[35,1869,1870,1873,1875,1878,1881,1883,1886],{"class":37,"line":412},[35,1871,1872],{"class":300},"    money ",[35,1874,438],{"class":296},[35,1876,1877],{"class":300}," xw.book.add_format({",[35,1879,1880],{"class":45},"\"num_format\"",[35,1882,307],{"class":300},[35,1884,1885],{"class":45},"\"#,##0\"",[35,1887,1888],{"class":300},"})\n",[35,1890,1891,1894,1896,1899,1902,1904,1907],{"class":37,"line":419},[35,1892,1893],{"class":300},"    xw.sheets[",[35,1895,1843],{"class":45},[35,1897,1898],{"class":300},"].set_column(",[35,1900,1901],{"class":45},"\"B:K\"",[35,1903,313],{"class":300},[35,1905,1906],{"class":292},"14",[35,1908,1909],{"class":300},", money)\n",[35,1911,1912,1914,1916,1918,1921,1923,1925],{"class":37,"line":432},[35,1913,1893],{"class":300},[35,1915,1857],{"class":45},[35,1917,1898],{"class":300},[35,1919,1920],{"class":45},"\"A:D\"",[35,1922,313],{"class":300},[35,1924,121],{"class":292},[35,1926,1909],{"class":300},[35,1928,1929,1931,1934,1936,1938,1940,1942,1944,1947,1950],{"class":37,"line":450},[35,1930,535],{"class":292},[35,1932,1933],{"class":300},"(table[[",[35,1935,304],{"class":45},[35,1937,313],{"class":300},[35,1939,316],{"class":45},[35,1941,313],{"class":300},[35,1943,1467],{"class":45},[35,1945,1946],{"class":300},"]].round(",[35,1948,1949],{"class":292},"2",[35,1951,1090],{"class":300},[10,1953,1954],{},"Showing the inputs next to the outputs matters: a scenario table without its assumptions invites arguments about what \"upside\" meant.",[20,1956,1958],{"id":1957},"on-windows-with-excel-let-excel-calculate","On Windows with Excel: let Excel calculate",[10,1960,1961],{},"When the model uses functions LibreOffice lacks, or the results must match Excel to the last decimal for an audit, drive Excel itself. xlwings opens the model once, and each scenario is a matter of writing the inputs and reading the outputs — Excel recalculates in between, with no files written at all:",[25,1963,1965],{"className":283,"code":1964,"language":285,"meta":30,"style":30},"import xlwings as xw\n\nwith xw.App(visible=False) as app:\n    book = app.books.open(r\"C:\\models\\pricing_model.xlsx\")\n    rows = {}\n    for name, overrides in SCENARIOS.items():\n        for key, value in {**base, **overrides}.items():\n            sheet, cell = split(INPUTS[key])\n            book.sheets[sheet].range(cell).value = value\n        rows[name] = {k: book.sheets[split(v)[0]].range(split(v)[1]).value for k, v in OUTPUTS.items()}\n    book.close()\n",[32,1966,1967,1979,1983,2004,2040,2049,2061,2080,2093,2102,2132],{"__ignoreMap":30},[35,1968,1969,1971,1974,1976],{"class":37,"line":38},[35,1970,473],{"class":296},[35,1972,1973],{"class":300}," xlwings ",[35,1975,970],{"class":296},[35,1977,1978],{"class":300}," xw\n",[35,1980,1981],{"class":37,"line":55},[35,1982,416],{"emptyLinePlaceholder":415},[35,1984,1985,1987,1990,1993,1995,1997,1999,2001],{"class":37,"line":62},[35,1986,1776],{"class":296},[35,1988,1989],{"class":300}," xw.App(",[35,1991,1992],{"class":707},"visible",[35,1994,438],{"class":296},[35,1996,1646],{"class":292},[35,1998,1050],{"class":300},[35,2000,970],{"class":296},[35,2002,2003],{"class":300}," app:\n",[35,2005,2006,2009,2011,2014,2017,2020,2024,2027,2030,2033,2035,2038],{"class":37,"line":389},[35,2007,2008],{"class":300},"    book ",[35,2010,438],{"class":296},[35,2012,2013],{"class":300}," app.books.open(",[35,2015,2016],{"class":296},"r",[35,2018,2019],{"class":45},"\"C:",[35,2021,2023],{"class":2022},"s_b0D","\\m",[35,2025,2026],{"class":45},"odels",[35,2028,2029],{"class":2022},"\\p",[35,2031,2032],{"class":45},"ricing_model",[35,2034,18],{"class":292},[35,2036,2037],{"class":45},"xlsx\"",[35,2039,447],{"class":300},[35,2041,2042,2045,2047],{"class":37,"line":412},[35,2043,2044],{"class":300},"    rows ",[35,2046,438],{"class":296},[35,2048,1224],{"class":300},[35,2050,2051,2053,2055,2057,2059],{"class":37,"line":419},[35,2052,749],{"class":296},[35,2054,722],{"class":300},[35,2056,523],{"class":296},[35,2058,727],{"class":292},[35,2060,730],{"class":300},[35,2062,2063,2066,2068,2070,2072,2074,2076,2078],{"class":37,"line":432},[35,2064,2065],{"class":296},"        for",[35,2067,752],{"class":300},[35,2069,523],{"class":296},[35,2071,301],{"class":300},[35,2073,759],{"class":296},[35,2075,762],{"class":300},[35,2077,759],{"class":296},[35,2079,767],{"class":300},[35,2081,2082,2085,2087,2089,2091],{"class":37,"line":450},[35,2083,2084],{"class":300},"            sheet, cell ",[35,2086,438],{"class":296},[35,2088,778],{"class":300},[35,2090,293],{"class":292},[35,2092,783],{"class":300},[35,2094,2095,2098,2100],{"class":37,"line":459},[35,2096,2097],{"class":300},"            book.sheets[sheet].range(cell).value ",[35,2099,438],{"class":296},[35,2101,794],{"class":300},[35,2103,2104,2107,2109,2112,2114,2117,2119,2122,2124,2126,2128,2130],{"class":37,"line":464},[35,2105,2106],{"class":300},"        rows[name] ",[35,2108,438],{"class":296},[35,2110,2111],{"class":300}," {k: book.sheets[split(v)[",[35,2113,111],{"class":292},[35,2115,2116],{"class":300},"]].range(split(v)[",[35,2118,511],{"class":292},[35,2120,2121],{"class":300},"]).value ",[35,2123,517],{"class":296},[35,2125,520],{"class":300},[35,2127,523],{"class":296},[35,2129,1028],{"class":292},[35,2131,529],{"class":300},[35,2133,2134],{"class":37,"line":479},[35,2135,2136],{"class":300},"    book.close()\n",[10,2138,2139,2140,18],{},"This is faster per scenario than writing files, because Excel stays open, but it needs Windows or macOS with Excel and a desktop session, as described in ",[14,2141,2143],{"href":2142},"\u002Fgetting-started-with-python-excel-automation\u002Fautomating-excel-with-xlwings-basics\u002Fread-and-write-a-live-excel-workbook-with-xlwings\u002F","Read and Write a Live Excel Workbook with xlwings",[20,2145,2147],{"id":2146},"common-pitfalls","Common pitfalls",[2149,2150,2151,2171,2177,2183,2189],"ul",{},[2152,2153,2154,2158,2159,2161,2162,2166,2167,2170],"li",{},[2155,2156,2157],"strong",{},"Reading formulas instead of results."," Use ",[32,2160,239],{}," on the ",[2163,2164,2165],"em",{},"calculated"," copies; on the originals it returns ",[32,2168,2169],{},"None",", because openpyxl never stored results.",[2152,2172,2173,2176],{},[2155,2174,2175],{},"One LibreOffice start per scenario."," Batch the files into one call; process start-up dominates the run time.",[2152,2178,2179,2182],{},[2155,2180,2181],{},"Hard-coded addresses scattered through the script."," Keep one mapping of inputs and outputs.",[2152,2184,2185,2188],{},[2155,2186,2187],{},"Functions LibreOffice does not implement."," Most models use standard functions that calculate identically; check any model that uses newer Excel functions by comparing the base case with Excel's own result once.",[2152,2190,2191,2194,2195,313,2198,2201,2202,2205],{},[2155,2192,2193],{},"Volatile functions."," ",[32,2196,2197],{},"RAND",[32,2199,2200],{},"TODAY"," and ",[32,2203,2204],{},"NOW"," recalculate differently on every run; fix them to inputs for reproducible scenarios.",[20,2207,2209],{"id":2208},"conclusion","Conclusion",[10,2211,2212,2213,2215],{},"Keep the logic in the workbook and put a thin Python layer around it: a mapping of input and output cells, scenarios as overrides on the base inputs, one copy of the model per run written with openpyxl, a single headless LibreOffice call to calculate them all, and ",[32,2214,239],{}," to read the answers back. The same loop produces sensitivity tables, and the results go back to the model's owners as a workbook of inputs and outputs side by side.",[20,2217,2219],{"id":2218},"frequently-asked-questions","Frequently asked questions",[10,2221,2222,2225],{},[2155,2223,2224],{},"Can Python run Excel what-if analysis without Excel installed?","\nYes. openpyxl writes the input values into copies of the model, and headless LibreOffice recalculates the formulas; openpyxl then reads the results with data_only=True. On Windows with Excel installed, xlwings can drive Excel itself instead.",[10,2227,2228,2231],{},[2155,2229,2230],{},"Why do I get None when reading formula results with openpyxl?","\nopenpyxl does not calculate formulas, and files it saves contain no cached results. Read results only from files that Excel or LibreOffice has calculated and saved.",[10,2233,2234,2237],{},[2155,2235,2236],{},"How do I run a sensitivity analysis on an Excel model in Python?","\nChange one input at a time by the same percentage up and down, recalculate each copy, and record the change in the output. Sorting the inputs by the size of the swing gives the data for a tornado chart.",[10,2239,2240,2243],{},[2155,2241,2242],{},"Should I rewrite the model in pandas instead?","\nOnly if Python will own the logic from now on. Keeping the formulas in Excel lets the people who own the assumptions review and change them, and Python only drives the runs.",[20,2245,2247],{"id":2246},"related","Related",[2149,2249,2250,2256,2263,2270],{},[2152,2251,2252,2253,2255],{},"Up one level: ",[14,2254,17],{"href":16}," — writing, reading and recalculating formulas.",[2152,2257,2258,2262],{},[14,2259,2261],{"href":2260},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fgoal-seek-in-python-instead-of-excel\u002F","Goal Seek in Python Instead of Excel"," — solving for the input that hits a target.",[2152,2264,2265,2269],{},[14,2266,2268],{"href":2267},"\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Fmap-formula-dependencies-in-a-workbook-with-python\u002F","Map Formula Dependencies in a Workbook with Python"," — finding which inputs a result depends on.",[2152,2271,2272,2276],{},[14,2273,2275],{"href":2274},"\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Fread-formula-results-with-openpyxl-data-only\u002F","Read Formula Results with openpyxl data_only"," — why results need a calculating application first.",[2278,2279,2280],"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 pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}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 .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}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 .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}html pre.shiki code .s_b0D, html code.shiki .s_b0D{--shiki-default:#22863A;--shiki-default-font-weight:bold;--shiki-dark:#72F088;--shiki-dark-font-weight:bold}",{"title":30,"searchDepth":55,"depth":55,"links":2282},[2283,2284,2285,2286,2287,2288,2289,2290,2291,2292,2293,2294],{"id":22,"depth":55,"text":23},{"id":272,"depth":55,"text":273},{"id":556,"depth":55,"text":557},{"id":866,"depth":55,"text":867},{"id":948,"depth":55,"text":949},{"id":1205,"depth":55,"text":1206},{"id":1762,"depth":55,"text":1763},{"id":1957,"depth":55,"text":1958},{"id":2146,"depth":55,"text":2147},{"id":2208,"depth":55,"text":2209},{"id":2218,"depth":55,"text":2219},{"id":2246,"depth":55,"text":2247},"2026-09-29","Keep the Excel model as the single source of truth and let Python drive it: define scenarios as input values, write one copy of the model per scenario with openpyxl, recalculate them all in one headless LibreOffice call, read the outputs back, build a sensitivity table and write a scenario comparison workbook.","md",[2299,2301,2303,2305],{"q":2224,"a":2300},"Yes. openpyxl writes the input values into copies of the model, and headless LibreOffice recalculates the formulas; openpyxl then reads the results with data_only=True. On Windows with Excel installed, xlwings can drive Excel itself instead.",{"q":2230,"a":2302},"openpyxl does not calculate formulas, and files it saves contain no cached results. Read results only from files that Excel or LibreOffice has calculated and saved.",{"q":2236,"a":2304},"Change one input at a time by the same percentage up and down, recalculate each copy, and record the change in the output. Sorting the inputs by the size of the swing gives the data for a tornado chart.",{"q":2242,"a":2306},"Only if Python will own the logic from now on. Keeping the formulas in Excel lets the people who own the assumptions review and change them, and Python only drives the runs.","run what-if scenarios on an excel model with python",null,{"breadcrumb":2310},[2311,2313,2316],{"name":2312,"item":803},"Home",{"name":2314,"item":2315},"Getting Started with Python Excel Automation","\u002Fgetting-started-with-python-excel-automation\u002F",{"name":17,"item":16},"\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Frun-what-if-scenarios-on-an-excel-model-with-python",{"title":2319,"description":2320},"Excel What-If Scenario Analysis Automated with Python","Automate Excel scenario analysis: set model inputs with openpyxl, batch-recalculate with headless LibreOffice, collect outputs with data_only, run ±10% sensitivity on every input and write a comparison and tornado table — no Excel needed.","run-what-if-scenarios-on-an-excel-model-with-python","getting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Frun-what-if-scenarios-on-an-excel-model-with-python\u002Findex",[2324,2325],"excel-formulas","formula-equivalents","how-to","BfZmr04ZFWg67dLFxqjMACjxJPAuzt1vwMrXmLFPjpU",[2329,2333],{"title":2330,"path":2331,"stem":2332,"children":-1},"Recalculate Excel Formulas Without Excel in Python","\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Frecalculate-excel-formulas-without-excel-in-python","getting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Frecalculate-excel-formulas-without-excel-in-python\u002Findex",{"title":2334,"path":2335,"stem":2336,"children":-1},"Turn an Excel Model into a Python Function","\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Fturn-an-excel-model-into-a-python-function","getting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python\u002Fturn-an-excel-model-into-a-python-function\u002Findex",[2338,2340],{"path":2339,"title":2314},"\u002Fgetting-started-with-python-excel-automation",{"path":2341,"title":17},"\u002Fgetting-started-with-python-excel-automation\u002Fworking-with-excel-formulas-in-python",[],[2344,2347,2351,2353,2356,2359],{"path":2345,"title":2346,"kind":2326},"\u002Fformatting-and-charting-excel-reports-with-python\u002Fbuilding-excel-reports-with-xlsxwriter\u002Fstore-formula-results-with-xlsxwriter","Store Formula Results with xlsxwriter So Every Viewer Shows Values",{"path":2348,"title":2349,"kind":2350},"\u002Fadvanced-data-transformation-and-cleaning\u002Fapplying-conditional-formatting-with-openpyxl\u002Fhighlight-cells-with-formula-errors-using-conditional-formatting","Highlight Cells with Formula Errors Using Conditional Formatting","fix",{"path":2352,"title":2261,"kind":2326},"\u002Fadvanced-data-transformation-and-cleaning\u002Fexcel-formula-equivalents-in-pandas\u002Fgoal-seek-in-python-instead-of-excel",{"path":2354,"title":2355,"kind":2326},"\u002Fadvanced-data-transformation-and-cleaning\u002Fvalidating-excel-data-with-python\u002Fcheck-that-totals-in-a-report-add-up","Check That Totals in a Report Add Up",{"path":2357,"title":2358,"kind":2326},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fcall-excel-worksheet-functions-through-microsoft-graph","Call Excel Worksheet Functions Through Microsoft Graph",{"path":2360,"title":2361,"kind":2326},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Frecalculate-excel-online-workbooks-with-graph-sessions","Recalculate Excel Online Workbooks with Graph Sessions",1790972075803]