Browse the docs
Language reference
Language
Everything the compiler builds, on one page: a model is a few declarations, a module is lines, and almost everything else is a function.
The shape of a model
A Rufineer model is a folder of .rfn files. Each file holds a few declarations, and each declaration becomes part of the workbook:
| Write | What it is | In Excel |
|---|---|---|
model "Name" { … } | settings: currency, day count, data workbook, sheet order | the workbook |
timeline { … } | the phases and the width of the columns | the Timing sheet |
inputs "Title" { … } | the numbers the model starts from | a sub-header of the Inputs sheet, under its module |
table name: unit | a column of numbers kept in the data workbook | rows on the Input tables sheet, its values across |
scenarios name "Title" { … } | alternatives for some inputs: EPC bids, term sheets, tax regimes | a column per case on the Inputs sheet |
module name "Title" { … } | calculations: one line per row | a calculation sheet |
statement name "Title" { … } | an income statement, cash flow or balance sheet | a statements sheet |
check, alert | something that must be true, or that someone should know | the Checks sheet |
dashboard { … }, sensitivities { … } | the results, charts and cases | the Dashboard and Sensitivities sheets |
Inside a module almost everything is a line: a name, a unit and a formula. What would be special syntax in other languages is a function here: windows of time, ramps, totals, IRRs, depreciation. Three things build more than one row and have a word of their own: balance, solve and funding.
A line can say what it is: revenue sales = …, cost om = …, capex turbines = … life 25y. Then Rufineer writes the sources and uses, the statements, the debt waterfall, the returns and the checks for you: see Conventions.
One file per module
Each module is one file, named after it, and one sheet in the workbook. The file holds the module's inputs blocks first, then its module block:
my_model/
├── model.rfn what the model is: name, currency, timeline, settings (sheets [...] order)
├── ops.rfn inputs "Plant" { … } then module ops "Operations" { … }
├── capex.rfn
├── debt.rfn
├── reports.rfn statements, dashboard, sensitivities (when you want them)
├── checks.rfn your own checks and alerts
└── data/tables.xlsx only if the model reads tablesThe inputs still all go on one Inputs sheet, under the title of the module written in the same file (Inputs). sheets [ops, capex, debt] in model.rfn sets the order of the calculation sheets.
A module as a folder
When a module grows, it can be a folder instead. Each file in debt/ writes module debt { … }, and may hold inputs blocks of its own; the parts join into one module and one sheet, in the order sheets lists them, then alphabetically:
debt/
├── senior.rfn inputs "Senior loan" { … } module debt "Debt" { senior = loan(…) }
├── mezzanine.rfn module debt { mezzanine = loan(…) }
└── reserves.rfn module debt { reserve dsra = 6m }sheets [ops, capex, debt/senior, debt/mezzanine, debt/reserves]Give the title (module debt "Debt") once, in any one of the files: two different titles are an error. | decimals N is the same.
The first layout
Models written before kept each module's inputs in inputs/ops.rfn, beside its calculations in calculations/ops.rfn, and the rest in outputs/. That layout still compiles exactly as before. In the app, the menu (☰) of such a model has Use one file per module…, which moves its files: each module's inputs/x.rfn and calculations/x.rfn become x.rfn, outputs/checks.rfn becomes checks.rfn, and the other outputs reports.rfn. The workbook doesn't change: the same sheets, and every number the same.
Seven rules the language keeps
Every part of the language follows these rules; when a feature would break one, it changes or waits.
- Symbols for maths, words for time. Arithmetic uses + - * / ^. The ideas newcomers find hard use plain words: during, prev, balance, grow, spread.
- Say what a number is, not where it goes. Modules become sheets, lines become rows and the timeline becomes columns. You never type a cell address.
- Units are checked. Adding USD to MWh is a compile error. Conversions are written into the formula where you can see them.
- The declared unit sets the time conversion. A line declared in USD whose formula gives USD/y is multiplied by the period's year fraction, visibly.
- One row, one formula. Every line becomes a single Excel row with the same formula in every period column.
- No hidden loops. A circular reference only compiles when a solve line breaks it. The workbook never uses iterative calculation.
- No cross-sheet references inside calculations. Each sheet begins with an Imports block, grouped so it folds away. Calculation rows only point at cells on their own sheet.
Conventions: say what a line is
Every project finance model has the same sheets after its assumptions: sources and uses, fixed assets, tax, the three statements, a cash waterfall, the returns, the checks. Rufineer writes them for you, the way Rails writes the plumbing of a web application, once your lines say what they are. You write the economics; Rufineer writes the accounting.
model "Sunbird Solar" {
currency USD
tax inputs.tax_rate // company tax, losses carried forward
discount inputs.discount_rate // the NPVs on the Returns sheet
}
module ops "Operations" {
energy: MWh = inputs.capacity * inputs.capacity_factor * 8_760 h/y | during operations
revenue sales = energy * inputs.tariff
cost om = inputs.capacity * inputs.om | during operations
}
module capex "Construction" {
capex epc = spread(inputs.epc, over: construction) life 25y
}
module debt "Debt" {
tenor = window(from: operations.start, for: 15y)
senior = loan(size: 70 % of costs, rate: inputs.rate, over: tenor, repay: annuity) "Senior loan"
reserve dsra = 6m "Debt service reserve"
}That is a whole model: exported, it has a balance sheet that balances, and the Starter (Start from the conventions in New model…) starts every model this way.
The kinds of line
A kind word goes in front of a line in any module. A kind line is money in the model's currency, so it needs no unit (a unit, if written, must be that currency). The kind words are read only before a line's name, so revenue: USD = … is still a line called revenue: none of them is a keyword.
| Write | Income statement | Cash flow | Balance sheet |
|---|---|---|---|
revenue sales = … | revenue | into CFADS | retained earnings, through profit |
cost_of_sales fuel = … | cost of sales, above a gross profit | out of CFADS | |
cost om = … | operating costs, above EBITDA | out of CFADS | |
income grant = … | other income, in EBITDA | into CFADS | |
capex epc = … life 25y allowance 5y, or a depreciation { … } block after it | depreciation over its life | a use of funds while building; after that, out of CFADS | fixed assets at cost less depreciation; with no life, at cost (land) |
use spares = … | a use of funds while building | an asset, at cost | |
shares pref up to …, shares ordinary = rest | a source of funds while building (see Funding) | share capital | |
senior = loan(size: 70 % of costs, …) | interest, less what is capitalised while building | draws in; interest and principal out, in the order written | the balance and interest accrued |
reserve dsra = 6m | paid in and drawn where it is written in the waterfall | an asset |
allowance 5y writes a capex off for tax over its own life; without it, tax depreciation is the book depreciation. An allowance shorter than the life is accelerated tax depreciation: life 25y allowance 4y writes a quarter off a year for four years, and the tax a project pays early on falls. With VAT, vat capitalised or vat none after a capex says what happens to its VAT.
Depreciation
A capex says how it is depreciated in a depreciation: record, an argument of its line's call that the conventions read (the call itself never sees it):
module capex "Construction" {
accepted = window(from: operations.start + 6m, for: 30y)
capex epc = spread(inputs.epc, over: construction,
depreciation: {life: 25y, method: straight_line, from: accepted.start}) "EPC"
capex kit = spread(inputs.kit, over: construction,
depreciation: {method: accelerated, schedule: [50 %, 25 %, 25 %]}) "Kit"
capex grid = spread(inputs.grid, over: construction,
depreciation: {life: 40y, allowance: [50 %, 25 %, 25 %]}) "Grid connection"
capex land = spread(inputs.land, over: construction, depreciation: {method: none}) "Land"
}| In the record | What it says |
|---|---|
method: straight_line (the default) | the same share of the cost each year over its life: |
method: accelerated | the share of the cost written off in each year of its life, schedule: [50 %, 25 %, 25 %], adding up to 100%: a special initial allowance, MACRS, a front-loaded book schedule. Each amount follows the schedule from when it is placed in service |
method: none | not depreciated: kept at cost, like land |
allowance: | for tax, its own life (5y) or schedule ([50 %, 25 %, 25 %]); the book depreciation, otherwise |
from: | when its depreciation starts: a date's name, one of the module's own (accepted.start) or any other; operations.start, otherwise |
life 25y allowance 5y after the formula still work, for a capex with nothing else to say, and a capex with neither (inputs.land | during construction.first) is kept at cost. Writing the life both ways, or a life with a schedule, is a mistake. depreciation: is read only on a capex line. The rows Rufineer writes are in the preview's Conventions tab (rfn conventions assets).
Financing costs are the loan's to say, not the asset's: what happens to a loan's fees and its interest while building is its financing_costs: (see Loans below). What is capitalised is shared among the depreciable assets by what each cost to build.
Loans
A loan needs no kind word: it is debt. With the conventions it takes four more arguments, and three it already had are filled in:
- How much it provides.
size: 70 % of costs(the capex, before financing costs),size: 70 % of uses(every use, its own fees and interest included: a loop Rufineer solves), or an amount,size: 50 mUSD;signed: base_casekeeps the base case's size in every sensitivity. It is drawn in the order the model'sdrawdownsays, pro rata unless it says otherwise. Or givedrawn: xyourself (a refinancing drawssenior.refinanced). Share capital, or the class ofsharesthat is therest, pays whatever the other sources don't. (The oldershare: 70 %, that share of each period's construction costs, still works in a model that declares nothing else.) - Its costs while building are uses of funds:
upfront_fee: 1.5 %of the size at financial close,commitment_fee: 0.5 %/yon what is not drawn yet.financing_costs:says where they go, with its interest while building:capitalised(the default: in the assets' cost, depreciated with them),amortised(the fees written off over the periods the loan is repaid, a finance cost under interest, IFRS 9; what is left is on the balance sheet, Loan fees not yet amortised; its interest while building stays capitalised, IAS 23) orexpensed(the fees and the interest a finance cost as they are charged). - Its rank. Loans are paid in the order they are written, each from the cash the ones above it leave (that is its
cash:); a refinancing takes the place of the loan it refinances;rank: 2moves a loan. A shortfall is owed as arrears, and an alert says so. - Interest while building is added to the balance (
interest_during_construction: rolled_up, the default), so it is a use of funds with no circular reference;interest_during_construction: paidpays it from the drawdowns, like any other use.capitalise: alwaysrolls it up for good;capitalise: neverdoesn't capitalise it. by: shareholdersmakes it a shareholder loan: it counts in the equity IRR, not as a lender with a DSCR.repay: sculpted(dscr: 1.30 x)sculpts to the cash flow Rufineer writes,cashflow.cfads.
Reserves
reserve dsra = 6m sits in the waterfall where it is written. It holds back that much of the debt service due on the loans above it (since the reserve before it), is drawn when their cash falls short, and is released as the target falls. Its rows are debt.dsra (the balance), dsra_target, dsra_opening and dsra_change. The target is the debt service ahead, which depends on the cash the reserve keeps back, so it is a solve line that the Solve macro settles. Write a formula instead of a length for a target of your own: reserve mra = inputs.major_maintenance.
VAT
VAT on the construction costs is paid while the plant is built and refunded later, so it has to be funded meanwhile. Three settings in the model block, and a word after a capex that is different:
model "Kariba Solar 50 MWp" {
…
vat inputs.vat_rate // VAT on every capex while building
vat_refund inputs.vat_refund // refunded this long after it is paid (3m unless said)
vat_facility inputs.vat_facility_rate // a facility pays it, repaid from the refunds
}
module capex "Construction" {
capex epc = spread(inputs.epc, over: construction) life 25y // recoverable
capex grid = spread(inputs.grid, over: construction) vat capitalised life 30y
capex development = inputs.development | during construction.first vat none life 25y
}| After a capex | Its VAT |
|---|---|
nothing, or vat recoverable | paid while building, refunded vat_refund later; until then the company is owed it (the VAT receivable) |
vat capitalised | never refunded: part of the asset's cost, depreciated (and written off for tax) with it |
vat none | none: zero-rated or exempt |
Rufineer writes a VAT sheet (vat): the VAT on each capex, the refunds, the receivable, and the facility. Both kinds of VAT are uses of funds on the Sources and uses sheet, so size: 70 % of uses includes them and of costs doesn't.
- Without a facility the sources pay the VAT in the drawdown's order, like any other use, and the refunds are the company's cash.
- With
vat_facility, the facility draws the VAT to be refunded as it is paid (a source of its own, before the others), the loans share what is left, and the refunds repay it. Its interest, on what it has drawn, is a financing cost while building, paid by the sources and capitalised in the assets; after that it is paid from the cash flow and is interest in the income statement. The facility is on the balance sheet, and checks say it is repaid and the VAT refunded by the end. - Not in CFADS. The refunds (less the facility's service) are added to the cash available for debt service, not to CFADS, so they don't flatter the DSCRs. The project cash flow pays the VAT and receives the refunds.
Rows to use: vat.paid, vat.capitalised, vat.refunds, vat.receivable, vat.facility, vat.facility_interest, vat.net. VAT needs the sources and uses declared, which vat does. A model's own module vat, with no vat in the model block, is its own (Tutorial 2 writes its VAT by hand).
A tax regime
tax inputs.tax_rate taxes the profit at a rate, with losses carried forward. A regime with more to it is a block:
model "Kariba Solar 50 MWp" {
…
tax {
rate inputs.tax_rate
holiday 5y // no tax for the first five years of operations
holiday_rate 5 % // or a lower rate in them (0 % unless said)
loss_expiry 6y // losses carried forward lapse after six years
loss_limit 50 % // losses set off at most half of a period's profit
interest_cap 30 % // interest deductible up to 30% of EBITDA, the rest carried forward
minimum 1 % // a minimum tax of 1% of revenue, when the tax is less
withholding 15 % // on dividends: the equity cash flow is after it
}
}Only rate is needed; each value is a formula, so an input that a sensitivity moves works. The Tax sheet applies them in order: the interest deductible (what isn't is carried forward and deducted when the cap allows), the losses (the oldest used first, as tax_losses' expire: and limit:), the rate (the holiday's in the holiday), then the minimum tax (not in the holiday). The tax as if there were no debt, for the project IRR, follows the same rules without the interest. Withholding tax is taken from the dividends in the equity cash flow (returns.withholding), not from the company's cash.
Accelerated tax depreciation is each capex's allowance (above): a shorter life, or a schedule like [50 %, 25 %, 25 %].
What Rufineer writes
| Sheet (module) | What is on it | Lines to use |
|---|---|---|
Sources and uses (sources) | each capex and other use while building, each loan's fees and interest; each source's size or commitment, its draws in the drawdown's order and what it has drawn to date; the totals | sources.uses, sources.equity, sources.total_uses, sources.senior_size, sources.building |
Fixed assets (assets) | each capex depreciated over its life, interest during construction shared among them by cost; tax allowances | assets.depreciation, assets.net_book_value |
VAT (vat), with vat in the model block | the VAT on each capex, the refunds, the receivable, the VAT facility | vat.paid, vat.refunds, vat.net |
Tax (tax) | taxable profit, losses carried forward, the tax; and the tax with no debt. With a regime: the holiday, the interest cap, losses that lapse, a minimum tax | tax.charge, tax.unlevered_charge |
Income statement (income) | your revenue and costs, EBITDA, depreciation, interest on each loan, tax, profit | income.revenue, income.ebitda, income.profit |
Cash flow (cashflow) | CFADS; each loan and reserve in rank order with the cash after it; dividends out of profits (the dividend trap), the cash at bank | cashflow.cfads, cashflow.after_senior, cashflow.dividends, cashflow.cash |
Balance sheet (balance) | fixed assets, reserves, cash; each loan; share capital and retained earnings; their difference | balance.total_assets, balance.difference |
Returns (returns) | project cash flow and IRR (after the tax it would pay with no debt), equity cash flow and IRR, NPVs at discount, each lender's DSCR and the minimum | returns.project_irr, returns.equity_irr, returns.min_dscr |
Then the income statement, cash flow and balance sheet by year (reports by half, by quarter or by month in the model block for shorter columns, reports none for neither), a dashboard if the model has none, and the checks: sources equal uses, the loans don't pay for more than the costs, the balance sheet balances, the cash never goes negative, each loan is repaid by the end of its window, and an alert for each loan paid late.
The preview's Conventions tab shows all of it as Rufineer, and conventions cashflow in the app's console (⌘K) one sheet. In Excel the sheets are like any other: live formulas, imports at the top, no iteration.
Changing what Rufineer writes
In the smallest piece that needs it:
| To | Write |
|---|---|
| replace one line | a line of that name in a module of that name: module cashflow { dividends: USD = … }. The other lines stay, and lines of the module are read by their names. |
| add a line to a sheet | a line with a new name in a module of that name: module returns { equity_multiple: x = multiple(equity_flow) } |
| replace a statement or the dashboard | your own statement annual_income … { … }, or a dashboard { … } |
| pay a loan from other cash, or draw it differently | give it cash: or drawn: yourself |
| write only the sources and uses | conventions funding in the model block: Rufineer writes the Sources and uses sheet from capex, use, shares and loans, and the model writes its own tax, waterfall and statements (Tutorial 2, Tutorial 4, By hand) |
| turn it all off | conventions off in the model block: kind lines are then plain lines in the model's currency |
The conventions need a phase called operations: what comes before it is construction. A model with no kind lines is built exactly as it is written.
Timeline
The timeline sets the model's columns. Phases run one after another, in the order you write them, and you name them.
timeline {
start 2027-01-01
fiscal_year ends Jun // optional; December by default
construction 18m step month // monthly columns while building
operations 25y step quarter // quarterly afterwards
spare 10y // optional; 10 years by default
}A phase's step is month, quarter, half or year; step on a line of its own sets it for every phase that doesn't give one. The columns change width at the phase boundaries, and everything that depends on time (year fractions, escalation, profiles, the financial year, the statements) follows the dates.
A phase's length can depend on a scenario: operations ppa(short: 20y, long: 25y) lasts 20 or 25 years, whichever case the switch picks. The columns are laid out for the longest case; the length on the Inputs sheet sits beside the switch and picks its case with CHOOSE, so every flag and date follows it, and the shorter case leaves more spare columns. Each case's length must be a whole number of the phase's steps.
Each phase gives you names to use in formulas:
| Name | Meaning |
|---|---|
operations | 1 in the phase, 0 outside it (a flag) |
operations.start, operations.end | its first and last day |
operations.first, operations.last | 1 in its first or last period |
operations.year, operations.period | 1, 2, 3 … years or periods into it |
Room to change it in Excel: spare
The phase lengths are inputs, and the Timing sheet works every date and flag out from them, so you can make operations 30 years instead of 25 on the Inputs sheet. For that the sheets have spare columns after the last phase: 10 years of them unless spare says otherwise, in the last phase's step (spare 0y for none).
The spare columns are outside the model. Every phase flag is 0 there, and so are after(…), windows, depreciation and the phase year counters. sum, avg, min, max, mirr and the checks stop at the end of the last phase, so nothing in the spare columns changes a result. Their dates show in grey italics. When a longer phase reaches them they turn back to normal, and the Dashboard counts the periods in the model and the spare columns left. The Checks sheet's The timeline fits in the columns fails if the timeline runs past the last column: rebuild with a longer spare.
Lines that are 0 outside their phase (| during operations) are 0 in the spare columns too. A line with no phase, like opex: USD = inputs.fixed, carries on into them, as it would into construction. The totals and results don't count it there, but the sheet shows it.
And every column has period.start, period.end, period.days, period.number, year_frac (its share of a year, by the model's day count) and financial_year; with mixed steps, period.months too.
Values and units
16_000_000 USD 0.095 USD/kWh 98.5 % 6.12 %/y 1.30 x
48 MWp 1_000 USD/unit/m 8_000 sqm 2027-01-01 18m 25y- Every number has a unit.
%is a share (98.5 %is 0.985),%/ya rate per year andxa ratio. Money is any three-letter currency code, andkUSDis thousands. - Units multiply and cancel. MWh × USD/kWh is USD, and the compiler writes the ×1000 into the formula where a reviewer can see it. Adding MWh to USD is an error.
- The time rule. A formula that gives an amount per year (USD/y, MWh/y), on a line declared per period (USD, MWh), is multiplied by the period's year fraction. Rates per month or per day convert the same way. So
1_000 USD/unit/m × 120 uniton aUSDline is ×12 × the year fraction: right in monthly and quarterly columns alike. - A number without a unit can scale but not be added.
revenue * 2is fine;revenue + 500is an error (write500 USD). A bare0or1fits anywhere:max(0, cash),1 + growth. - Currencies are units too.
ZAR÷ZAR/USDisUSD, so an exchange rate converts an amount and the compiler checks it's the right way up. See Several currencies. - Dates. A date plus a duration is a date (
operations.start + 12.5y), and a date minus a date is a number of days.
The units Rufineer knows:
| Kind | Units |
|---|---|
| time | y, m (month), d, h; night (a day) |
| money | USD, EUR, ZAR … (any three capital letters), kUSD, mUSD |
| plain | %, x |
| power and energy | W, kW, MW, GW; Wp, kWp, MWp, MWac; kWh, MWh, GWh |
| area | sqm, sqft, ha, acre (not m2: m is a month) |
| things counted | unit (apartments, houses), lot, space (parking), room or key, bed, veh (vehicles), bag (goods sold by the bag: 20 bag/t) |
| commodities | g, kg, t, kt, Mt; oz (the troy ounce, 31.1034768 g, so 4_144 USD/oz × kilograms of gold is money); bbl, kbbl, MMbbl |
Your own units
When a model counts something Rufineer doesn't know (bottles, sacks, tyres, passengers) or uses a measure it doesn't have (pounds, basis points), declare it in a units block, in model.rfn or any other file. Every file of the model can use it.
units {
bottle "Bottles of wine"
case_12 = 12 bottle "Cases of 12"
pallet = 60 case_12
lb = 0.45359237 kg
bp = 0.01 % "Basis points"
}
inputs "Sales" {
pallets = 40 pallet/m "Pallets shipped a month"
price = 96 USD/case_12
grapes = 2.9 lb/bottle
discount = 150 bp
}- A new kind of thing is just its name:
bottle. It is counted, asunitandbagare: amounts of it add up across periods, and adding bottles to dollars is an error. - So many of another unit (
pallet = 60 case_12,lb = 0.45359237 kg,bp = 0.01 %) is the same kind of quantity at another scale. The compiler converts between them as it doeskgandt, and writes the factor into the formula: a line incase_12that reads bottles is/12, grapes inkgpriced inUSD/lbare*2.20462262185, and150 bpis/10000. - From then on they are units like any other: in inputs (
96 USD/case_12), on lines (shipped: bottle = inputs.pallets), and per period (40 pallet/mis converted to each period's length, by the time rule). - A unit's name is letters, digits and
_. It can't be a unit Rufineer already knows (kg), three capitals (that is a currency), or a word of the language (during,case). A unit may be defined from one declared after it, but not from itself. - In the editor, hover a unit to see what it is (one pallet is 720 bottle), and press F12 to go to its declaration. The outline lists the block.
Several currencies
The model has one currency (currency USD), and its results are in it. Costs paid in another currency are written in that currency and converted at an exchange rate, which is an input like any other and can move:
inputs "Exchange rate" {
zar_per_usd = 18.5 ZAR/USD | grow 3 %/y from operations.start
}
inputs "Running costs" {
local_costs = 30_000_000 ZAR/y | grow 5 %/y from operations.start
}
module ops "Operations" {
local_costs: USD = inputs.local_costs / inputs.zar_per_usd | during operations
}- Each period at its own rate. The rate is a row like the costs, so each period's rand are converted at that period's rate: the costs rise with local inflation and the rand weakens, and the line shows what that leaves in dollars.
- The units say which way. ZAR/y ÷ ZAR/USD is USD/y, and on a
USDline that is × the year fraction. Multiply by mistake and the units come out as ZAR²/USD, which is an error. - Forget to convert, and the compiler names the rate. A rand amount on a dollar line, or rand added to dollars, is an error whose help says to turn ZAR into USD, divide by
inputs.zar_per_usd(ZAR/USD), finding the rate among your inputs whichever way up it is quoted. - An amount spread in its own currency.
civil_zar: ZAR = spread(inputs.civil_works, over: construction), thencivil_zar / inputs.zar_per_usd: each period's share is converted at that period's rate.
Tutorial 5 builds a hydro plant with costs in rand and a PPA in dollars.
Converting: convert
convert(x) is an amount in another currency in the model's, each period at that period's rate. It finds the rate itself: the fx line for that currency, or else the one input quoted between the two currencies, whichever way up. at: names another.
module capex "Construction" {
panels_cny: CNY = spread(inputs.panels, over: construction) "Panels and inverters, in yuan"
capex panels = convert(panels_cny) "Panels and inverters"
}
module ops "Operations" {
cost spv = convert(inputs.spv_costs, at: inputs.zar_per_usd) | during operations "Company costs"
}- Divided or multiplied, as the rate is quoted. By a rate in CNY/USD, yuan are divided; by one in USD/CNY, multiplied. The result is in the model's currency, per year if the amount was (
ZAR/ybecomesUSD/y). - Two rates for a currency (the spot and the forward inputs, say) and no
at:is an error that lists them: say which, or write anfxline, whichconverttakes first. - A kind line is in the model's currency.
capex panels = convert(panels_cny)is how one paid in yuan is written: the yuan on a line of their own, the kind line converted. - An amount already in the model's currency is left as it is.
Forward cover: fx
A cost paid in another currency is often bought forward: at financial close the company agrees the rate it will pay the yuan at, for some or all of them. fx is that rate, as a line of its own:
module fx "Exchange rates" {
cny = fx(spot: inputs.cny_per_usd, forward: inputs.cny_forward, hedged: 80 %)
| during construction "Yuan per US dollar"
}| Write | What it says |
|---|---|
spot: | the spot rate, any way up (CNY/USD or USD/CNY); it can move: | grow 1 %/y on the input |
forward: | the rate the hedge locks in, quoted as the spot is; or points:, forward points added to spot |
hedged: | the share bought forward: 100 %, an input, a curve. | during construction after the call says when; outside it nothing is hedged |
cny is the blended rate: what is hedged converts at the forward rate and the rest at spot, so convert(x) (which takes it before the inputs) converts the whole at that blend. For a rate quoted as yuan per dollar that is 1 ÷ (h ÷ forward + (1 − h) ÷ spot); for dollars per yuan, h × forward + (1 − h) × spot. Its rows are cny.spot, cny.forward and cny.hedged. With spot: alone it is the spot rate, a line convert finds. What the cover saved (or cost) is the difference: convert(x, at: inputs.cny_per_usd) - convert(x).
The rate a loan pays can be hedged too: see swap.
Model settings
model "Highveld Wind 40 MW" {
currency USD
display thousands // calculation sheets show USD '000
day_count act/365 // act/365, act/360 or 30/360
data "data/tables.xlsx" // the data workbook, for tables
sheets [ops, capex, debt, tax, cash, returns]
}sheets sets the order of the calculation sheets; modules not listed follow (a sheet the conventions write, like sources, can be listed too). rfn generate module adds each new module to it. With the conventions: tax (a rate, or a regime), discount, reports, conventions off or conventions funding, drawdown, the order the sources of funds are drawn in (Funding), and vat, vat_refund and vat_facility (VAT).
navigator off leaves the Navigator out of the exported workbook. It is on unless said: a sheet after the Dashboard with a link to every sheet and every input group (each module's inputs, each inputs block) and to the sections of the calculation sheets, and a Navigate button at the top of every sheet that comes back to it. The button is a link, not a macro, so it works in the .xlsx and the .xlsm, in Excel on Windows, the Mac and the web.
An evaluation copy: valid_until
A model sent out for review often shouldn't float around for ever. valid_until stamps the exported workbook with the date it is valid to:
model "Kariba Solar 50 MWp" {
…
valid_until 2026-12-15 "contact deals@example.com for a current version"
}Every sheet then shows an amber banner in its title area — Valid until 15 December 2026 — contact deals@example.com for a current version (the message is optional). That is all it does: a visible label. It changes no cell, runs no macro, and nothing happens to the file on or after the date; the recipient keeps their copy and their work. It is off unless you set it, and only in the exported workbook.
Rufineer will not build a workbook that deletes or hides data on a date, overwrites the recipient's file, or hides a password in a macro. A model that has to stop being useful after a date should say so plainly, as this does, not sabotage the reader.
Decimal places
Each number is shown as its unit suits it: money as whole numbers (1,000,000), percentages and ratios to two places (2.50%, 1.30x), and anything else, a rate such as USD/y included, to two places or more (1,000,000.00). decimals sets the places instead, for the whole model, a sheet or one line; the nearest one counts:
model "Highveld Wind 40 MW" {
currency USD
decimals 0 // every number
}
inputs "Revenue" | decimals 2 { // this block's inputs
tariff = 85 USD/MWh | grow 2.5 %/y from operations.start | decimals 1 // this one
}
module ops "Operations" | decimals 1 { // this sheet's lines
energy: MWh = … | decimals 0 // this line
}
statement income "Income statement" | sum by year | decimals 0 {
+ ops.revenue | decimals 2 "Revenue" // this row
}On a line, | decimals N goes with its other modifiers; on a module, an inputs block or a statement, after its title. N is 0 to 10.
- The model's, a block's and a sheet's are for amounts: money, counts, and rates such as
USD/y. Percentages, ratios, indices and year fractions keep their own places, sodecimals 0doesn't turn 2.50% into 3%. A line's own| decimals Nsets any number, a percentage too. - A module's are its inputs' too: the inputs blocks in
ops.rfntakemodule ops … | decimals 0's places (a block's own setting comes first), and so do their escalated rows on the Indexation sheet. An input's places are not its growth rate's. - A loan's or a solve line's places are also those of the rows it makes. An import row shows its source line's places, unless its own sheet sets some.
- The preview and the exported workbook show the same: the workbook's number formats carry the places, and the values themselves are never rounded. Dates, flags, years and checks are not changed.
Inputs
inputs "Revenue" {
/// Paid by the mine under a 25-year PPA
tariff = 0.095 USD/kWh @source "PPA, clause 9"
escalation = 4 %/y @note "From the second year"
om = 5.5 USD/kWp/y | grow 2.5 %/y from operations.start
fee = 2 % "Arranging fee, % of the loan"
}- Every
inputsblock is a sub-header of the Inputs sheet, in input styling. Formulas readinputs.tariff. - Grouped like the calculations. An inputs block goes under the module written in the same file, so the blocks in
ops.rfnsit under moduleops's title, in the order of the sheets, and the Inputs sheet reads Operations › Revenue › Tariff. A file with only inputs goes under the module it is named after (the blocks intax.rfnunder the Tax sheet the conventions write), and a file in a module's folder under that module. A block with the module's own title doesn't repeat it, and a block anywhere else (model.rfn) is a section of its own, first. - The Inputs sheet labels an input after its name (Escalation); a string after the value gives it a label of its own, as lines have:
fee = 2 % "Arranging fee, % of the loan". section "Fixed costs"inside a block adds a smaller sub-header over the inputs after it.///above an input,@sourceand@notego in its Notes column. A note can follow| grow …too.| grow rate from dateescalates the input once a year from the date; the escalated row is on the Indexation sheet and the name reads it. (For escalation inside a formula, usegrow(rate).)- Numbers inside formulas are moved here too:
cfads / 1.30 xputs 1.30 on the Inputs sheet as a constant used in formulas, because FAST allows no hard-coded numbers in calculations.
Scenarios
Scenarios are alternatives for some of the inputs, side by side: the EPC bids, the banks' term sheets, the tax regimes. Declare the cases once, then give the inputs that differ a value per case:
/// The EPC bids: the model uses one at a time
scenarios design "EPC bid" {
bid_a "Bidder A"
bid_b "Bidder B"
bid_c "Bidder C"
}
inputs "Plant" {
capacity = design(bid_a: 30 MW, bid_b: 29.4 MW, bid_c: 30.5 MW)
capex = design(bid_a: 28.4 mUSD, bid_b: 25.9 mUSD, bid_c: 27.1 mUSD)
yield = 2_050 kWh/kW/y // the same for every bid
}- A switch at the top. The first thing on the Inputs sheet is a Scenarios section with a drop-down per
scenariosblock. Pick Bidder B, press Solve, and the whole model is Bidder B's. The switch starts on the first case, or on the one afterstart:scenarios design "EPC bid" start bid_b { … }. Under each switch is its case number,MATCH(switch, titles, 0), which the formulas use. - The cases side by side. The inputs that differ by case sit right under their switch, a column per case from E, lined up under the cases' titles on the switch's row. Column D holds the value in use,
CHOOSE(case number, E, F, G), and the rest of the model reads column D as it reads any input. They are written in theinputsblock where they belong; the sheet moves them up. - Seen from every sheet. The band at the top of every other sheet shows the cases in use (EPC bid: Bidder A), each a link to its switch. The Dashboard shows them too.
- Only what differs. A case left out takes the first case's value:
design(bid_a: 16 mUSD, bid_b: 15.2 mUSD)gives Bidder C Bidder A's capex. Every case's value must be in the same unit, or one that converts (19_500 kWnext to20 MW). - A timeline that depends on the case. A phase can last a different time in each case:
operations ppa(short: 20y, long: 25y)(see Timeline). - Several sets. A model can have as many
scenariosblocks as it needs (technical, financing, tax …), each with its own switch, and an input with| growcan take a base value per case. - Every case in one run. A sensitivity case can use another scenario,
case "Bidder B" { design = bid_b }, so Run sensitivities tabulates the results of every bid (see Dashboard and sensitivities). The sensitivity case has a switch at the top too, Base case or any case by its title.
Tables from the data workbook
A list too long to type, such as a monthly S-curve, lives in the data workbook: one sheet per table, labels in column A, values in column B.
/// Share of the contract price paid in each month of construction
table build_profile: % sheet "Build profile"Write the table line, run data in the console (or Add the missing sheets in the workbook view): it adds the sheet, with one labelled row per period of the phase the table is spread over. Open data/tables.xlsx from the file tree, Download it, type the values in Excel and save, then Replace… it with your copy; the preview reads them again. The values are copied into the workbook, so the one you send doesn't depend on the data file: on the Input tables sheet, next to Inputs, each table has its keys across (Year 2026, 2027 … or Month 1, 2, 3 …) and a row of values under them, from column H as on a calculation sheet. A table is used as a profile: spread(total, over: construction, profile: inputs.build_profile), with a check that it adds up to 100%, or read by position with lookup.
A table can have columns side by side. by a scenario group gives a column per case, and the switch picks the one in use; by a list names the columns, and a lookup says which it reads:
table generation: MWh/y sheet "Generation" by yield_case // Year | P50 | P75 | P90
table escalation: % sheet "Escalation" by [one, two, cpi] // Year | 1% | 2% | CPI
per_turbine: MWh/y = lookup(inputs.generation, year_index) // the case in use
p90: MWh/y = lookup(inputs.generation, year_index, case: p90) // always P90
cpi_rate: % = lookup(inputs.escalation, year_index, column: cpi)On the Input tables sheet each column is a row under the keys (for a scenario, an In use row first, which the switch picks with CHOOSE), and rfn data makes the data sheet with a titled column for each.
A sheet that reads a table imports each column it uses as one row, its values across under a row of the table's keys (Month 1, 2, 3 … or the years), and the lookup reads that row: INDEX($H$40:$V$40, n). A table longer than the timeline is imported down column D instead.
Lines
/// Becomes the row's note in Excel
name: unit = formula | during window "Label"- One line, one row. The same formula runs across every period. The compiler works out whether a line is a single value (a constant) or a row.
- The unit is optional when the compiler can work it out; declare it to have it checked, and for the time rule.
- The label is the name in sentence case (
profit_before_taxis Profit before tax) unless you give one. - Names in other modules are written with the module:
debt.interest. Each sheet imports what it reads at the top, and calculations read only their own sheet. - Sub-headers.
section "Revenue"between lines puts a sub-header over the rows after it, with a blank row above. Balances, depreciation and funding get a smaller heading of their own. - Values in titles. A section's title can show a value, its name in braces:
section "Capital allowances: 25% a year for {inputs.allowance_life} years from COD". The name is read as it would be in a formula (inside a module, its own lines by their short names) and must be one value, not a row;{{and}}are braces themselves. The workbook writes the title as a formula,="… for "&TEXT($D$11,"General")&" years from COD", reading the value through the sheet's imports, so the title changes with the input. Percentages read 25.75%, money 1,250,000, ratios 1.30x and dates 1 Jul 2027. Sections ininputsblocks can show inputs the same way.
Formulas
Arithmetic + - * / ^, comparisons == != < <= > >=, and, or, brackets, and a choice:
fee: USD = if drawdown > 0 then inputs.fee * drawdown else 0A comparison is 1 or 0, so it can multiply: rent * (occupancy > 0).
Only in some periods: | during
om: USD = inputs.om * inputs.capacity | during operations // a phase
principal: USD = size / sum(tenor) | during tenor // a window
early: USD = inputs.incentive | during operations.year <= 5 // a condition
retail: USD = area * rent | during after(operations.start + 6m)| during makes the line zero outside the phase, window or condition. It is the one way to limit a line to some periods; in Excel it multiplies by the flag (or uses IF when the formula divides, so there's no #DIV/0!).
Functions
Most of Rufineer is here. Each function becomes plain Excel you can read.
Time
| Write | Meaning |
|---|---|
prev(x), next(x) | last period's value; next period's |
prev(x, 12) | x twelve periods (columns) back, 0 before the first: a refund that lags a number of columns whatever their length. For calendar months use lag |
cumulative(x) | the running total (of a flag: the number of periods so far, a plain number) |
ahead(x, 6m) | x added up over the next 6 months (a reserve target on the next half-year's debt service) |
trailing(x, 12m) | x added up over the last 12 months, this period included (an exit value, a backward-looking DSCR) |
lag(x, 6m) | x moved 6 months later (a VAT refund, receivables paid late) |
grow(rate), decline(rate) | an index that steps once a year from the start of operations (or from: a date) |
ramp(from: date, over: 12m) | a share that climbs from 0 to 1 in a straight line: a lease-up, a ramp-up |
spread(total, over: phase, profile: table) | a total spread over a phase, evenly or along a profile |
The lengths are lengths of time, not numbers of periods, so they mean the same in monthly and quarterly columns.
Windows of time
| Write | Meaning |
|---|---|
tenor = window(from: date, for: 7y) | a window, as a line of its own: tenor is 1 inside it, tenor.start and tenor.end are its dates |
after(date), before(date) | 1 in the periods that start on or after the date, or end before it |
Totals: one number
| Write | Meaning |
|---|---|
sum(x), avg(x), min(x), max(x) | over the whole timeline |
sum(x, during: tenor) | over the periods of a phase, a window or a condition: avg(dscr, during: repaying), sum(noi, during: operations.year == 2) |
sum(table) | a table's values added up (the column or case in use) |
Totals by group: a line
| Write | Meaning |
|---|---|
sum(x, by: tax_year) | in each period, x over every period with the same key: each quarter of a tax year sees the year's total. Also avg, min, max (SUMIFS, MAXIFS …) |
sum(x, by: tax_year, at: tax_year - 5) | the total of another group: the losses made five tax years ago |
Returns
| Write | Meaning |
|---|---|
irr(flows) | the IRR (XIRR, each flow dated at its period's end) |
npv(rate, flows, at: date) | the NPV (XNPV), valued at a date: the model's start unless you say. The flows can be energy too, for a levelised cost: npv(wacc, costs) / npv(wacc, output) |
mirr(flows, finance: r, reinvest: r) | the modified IRR, dated the same way |
multiple(flows) | money out ÷ money in: the equity multiple |
payback(flows) | years from the model's start to the end of the period in which the running total of the flows turns positive (0 if it never does); the running total gets a row of its own |
effective(rate) | a yearly rate compounded over the period, (1 + r)year fraction − 1, for a hurdle that must agree with the IRR |
Curves and schedules
| Write | Meaning |
|---|---|
lookup(table, n), lookup(table, n, column: cpi) | the table's n-th value (of a named column or case, for a table with several). Before the first it is the first; after the last, the last carries on; a fraction rounds down. A curve typed by year: lookup(inputs.prices, financial_year - inputs.first_year + 1) |
by_age(amounts, rates) | each amount spread over the periods after it, by a row whose k-th value is the rate for an amount k periods old. Tax depreciation of each addition by its own MACRS schedule: by_age(entering_service, macrs_by_quarter) |
With MACRS rates by year in a table, the rate for a quarter of age is lookup(inputs.macrs_15, (period.number + 3) / 4) / 4. Ages count columns, so in a quarterly phase they are quarters.
Maths
min(a, b), max(a, b), clamp(x, low, high), abs(x), round(x, 2) (to 2 decimal places; 0 for whole numbers, −3 to the thousand).
Dates
year(d) and month(d) are the calendar year and month (1–12) of a date, as plain numbers; date(y, m, d) makes a date from plain numbers: year_days = date(year(period.end), 12, 31) - date(year(period.end), 1, 1) + 1. The days between two dates are a plain number too. workdays(from: a, to: b) counts the days Monday to Friday from a to b, both included (Excel's NETWORKDAYS), for buying days or working days in a period: buying_days: d = workdays(from: max(period.start, inputs.start), to: period.end).
Library components
A few functions build several rows, and are written as a whole line:
plant = depreciation(cost: capex.spend, life: 20y, from: operations.start)
losses = tax_losses(profit: taxable)
cny = fx(spot: inputs.cny_per_usd, forward: inputs.cny_forward, hedged: 100 %) | during construction
base = swap(floating: inputs.sofr, fixed: inputs.swap_rate, hedged: 75 %)
size: USD = base_case(inputs.gearing * capex.total_cost)
toll = seek(inputs.toll, returns.equity_irr == inputs.target_irr)depreciation: straight line, each amount over its own life from when it is placed in service, with a book value that stops at zero (plant.book_value). Withschedule: [50 %, 25 %, 25 %]instead oflife:it is accelerated: each amount written off a share a year by its own age (a later addition starts its own schedule), the shares adding up to 100%; a row for each year shows what is in it (plant.year_1…).tax_losses: losses carried forward (losses.taxable,losses.closing).expire: 6ylets losses lapse when they are that old and unused, the oldest used first (losses.expired);limit: 50 %sets off at most that share of a period's profit.fx: an exchange rate with forward cover (Forward cover).swap: a base rate with an interest rate swap (Hedging the rate).loan,dscr,llcr: a loan repaid equal, annuity, bullet, sculpted or by a sweep, junior to other debt, with interest rolled up or refinanced, and its cover ratios. See Loans.base_case(…): in every sensitivity case, the value the line had in the base case: a signed loan doesn't grow with a cost overrun. The Solve macro stores it.seek(input, left == right): Goal Seek, the input the Solve macro changes until the target is met. See Meeting a target.
Balances
A balance carries forward from one period to the next: a loan, a reserve, cash, share capital.
balance loan: USD {
+ capex.senior "drawn"
- principal "repaid"
}It builds the FAST corkscrew: an opening row (last period's closing), one row per flow, and a closing row. Use loan.opening and loan.closing, or just loan for the closing. start x as its first row gives an opening value.
Loans: repayment, DSCR and LLCR
A balance is any corkscrew. A loan is a corkscrew that knows how it is repaid: one line builds the payment dates, the balance, interest charged and paid, and the debt service.
senior = loan(drawn: senior_draw, rate: inputs.senior_rate, over: senior_tenor,
repay: sculpted(cfads: ops.cfads, dscr: inputs.target_dscr),
interest_every: 3m, principal_every: 6m)
senior_dscr: x = dscr(cfads: ops.cfads, loan: senior)
senior_llcr: x = llcr(cfads: ops.cfads, loan: senior)How it is repaid: repay:
| Write | Principal on each principal date |
|---|---|
repay: equal | the same amount each time: the balance when repayment starts over the number of instalments |
repay: annuity | level payments of interest and principal (an amortising loan, like a mortgage): PPMT at the rate for each instalment's length |
repay: bullet | nothing until the window's last period, then all of it |
repay: sculpted(cfads: x, dscr: 1.30 x) | what the cash flow since the last principal date allows at the target DSCR, less the interest paid since then |
repay: sweep | all the cash left after interest, until the loan is repaid (a shareholder loan, a cash sweep); needs cash: |
Whatever the method, the last principal date repays what is left, and no payment is more than the balance.
When it is paid: interest_every: and principal_every:
Each is a length of time counted from the start of over:: 3m quarterly, 6m half-yearly, 12m once a year. Left out, it is every period. Interest and principal can differ: interest quarterly and principal half-yearly, as above, or interest yearly on a bullet. Between interest dates interest accrues, and the next interest date pays all of it. The window's last period is always a payment date. A length shorter than the columns (1m in a quarterly model) makes every period a payment date.
What it gives you
senior is the closing balance. Its rows are there to use: senior.opening, senior.drawn, senior.repaid, senior.interest (charged), senior.interest_paid, senior.service, and the flags senior.interest_date, senior.principal_date and senior.test_date. A sculpted loan adds senior.allowed, the debt service the target DSCR allows.
Junior debt and refinancing
Three more arguments make a loan junior, roll its interest up, or refinance it:
senior = loan(drawn: capex.senior, rate: inputs.senior_rate, over: senior_tenor,
repay: annuity, capitalise: construction,
refinance: operations.start + 5y)
refi = loan(drawn: senior.refinanced, rate: inputs.refi_rate, over: refi_tenor, repay: annuity)
mezz = loan(drawn: capex.mezz, rate: inputs.mezz_rate, over: mezz_tenor, repay: equal,
cash: waterfall.after_senior, capitalise: construction)
shl = loan(drawn: capex.shl, rate: inputs.shl_rate, over: operations, repay: sweep,
cash: waterfall.after_mezz, capitalise: always)| Write | What it does | Rows it adds |
|---|---|---|
cash: x | pays interest first, then principal, only as far as the cash x goes: the cash left after the debt that ranks above. Interest not paid stays accrued; principal not paid is owed as arrears and paid when the cash allows. | .cash, .interest_due, .principal_due, .arrears |
capitalise: construction | interest charged while the flag is 1 is added to the balance instead of paid (PIK). A phase, a window, any flag, or always for interest rolled up for the loan's whole life. | .capitalising, .capitalised |
refinance: date | the period that holds the date repays the whole balance and the interest accrued. senior.refinanced is that amount, for the new loan to draw, so the two always match. | .refinancing, .refinanced |
A loan paid from cash: can't make the cash negative, so the dividends under it never are. alert "Mezzanine paid late" debt.mezz.arrears == 0 says when it fell behind. The cash a junior loan reads is usually a line in another module (waterfall.after_senior), which reads the senior loan's service: that is not a loop, because the senior loan doesn't read it back.
Hedging the rate: swap
A loan at a floating rate (SOFR plus a margin) is usually swapped, for some of its life or some of its balance, to a fixed rate. swap is the base rate that leaves, as a line of its own; the loan pays its margin over it:
base = swap(floating: inputs.sofr, fixed: inputs.swap_rate, hedged: 75 %, spread: 0.1 %/y)
| during tenor "Base rate"
senior = loan(drawn: capex.senior, rate: base + inputs.margin, over: tenor, repay: annuity)| Write | What it says |
|---|---|
floating: | the floating rate: an input, a curve from the data workbook |
fixed: | the swap rate |
hedged: | the share swapped: 75 %, or a profile that steps down as the loan is repaid. | during … after the call says when; outside it, it all floats |
spread: | the swap's credit spread, added to the fixed rate (none unless given) |
base is h × (fixed + spread) + (1 − h) × floating, in %/y; its rows are base.floating, base.fixed, base.spread and base.hedged. A sensitivity on the floating rate then moves only what isn't swapped.
Cover ratios
dscr(cfads:, loan:)is measured on the loan's test dates: its principal dates (its interest dates for a bullet), while it is being repaid. Cash flow since the last test date over debt service paid since then, so quarterly cash against half-yearly payments is compared like for like. It is 0 on other dates: writemin(senior_dscr, during: senior.test_date), notmin(senior_dscr).llcr(cfads:, loan:), the loan life cover ratio: the cash flow still to come in the loan's window, discounted to the test date at the loan's rate, over the balance then outstanding. It is 0 once the loan is repaid, so its minimum ismin(senior_llcr, during: senior_llcr > 0).
Sizing a sculpted loan
Sculpting repays the loan out of what the cash flow allows. To repay it exactly, with no balloon at the end, size it as the present value of that allowance at the loan's rate:
capacity: USD = senior.allowed | during senior.principal_date
quarterly: %/y = ((1 + inputs.senior_rate * 3m) ^ 4 - 1) / 1y
senior_size: USD = npv(quarterly, capacity, at: operations.start)The DSCR is then the target on every test date, and the LLCR starts at it. examples/loans has four loans, one for each method.
Funding: sources and uses
With the conventions the sources and uses are declared: each use and each source says what it is, and the model block says in what order the sources are drawn. Rufineer writes the Sources and uses sheet. A funding block writes it by hand, for a funding no declaration fits.
Declared
model "Mopane Solar 40 MWac" {
…
conventions funding // only the sources and uses (leave it out for all the conventions)
drawdown {
before financial_close: ordinary
from financial_close: pro_rata on uses
equity in_order [pref_a, pref_b, shl, ordinary]
}
}
module capex "Construction" {
capex development_spend = spread(development_budget, over: development)
capex build = spread(epc + contingency, over: construction)
use initial_wc = ops.initial_wc | during construction.last
shares pref_a up to inputs.pref_a "Preference A"
shares pref_b up to inputs.pref_b "Preference B"
shares ordinary = rest "Ordinary equity"
}
module debt "Debt" {
senior = loan(size: inputs.gearing of costs, signed: base_case,
upfront_fee: inputs.senior_arrangement, commitment_fee: inputs.senior_commitment,
interest_during_construction: paid,
rate: inputs.senior_rate, over: tenor, repay: equal) "Senior loan"
shl = loan(by: shareholders, size: inputs.shl_size, …) "Shareholder loan"
}| Write | What it is |
|---|---|
capex x = … | a cost to build (depreciated over its life with all the conventions) |
use x = … | any other use: initial working capital, a reserve funded at close, another facility's fees |
loan(size: …) | a loan that provides up to its size: 70 % of costs (the capex), 70 % of uses (every use, fees and interest included, solved), or an amount. Its upfront_fee:, commitment_fee: and, with interest_during_construction: paid, its interest while building are uses. by: shareholders makes it the shareholders' money. |
shares x up to … | a class of shares that subscribes up to its commitment (of uses works here too) |
shares x = rest | the class that pays whatever the others don't (with none, the share capital does) |
Financial close is the start of the phase before operations (construction). With no phase before operations, the uses are paid for in the first period of operations, and financial close is that period (Tutorial 4's kiln). Before it, the rest pays, or the source before financial_close: names. From it, the order:
from financial_close: | Each period |
|---|---|
pro_rata on uses (the default) | each loan pays its share of the uses, its size over the uses from financial close (solved when the uses depend on the draws), so all finish together; the equity pays the rest, in the order equity in_order [ … ] gives (the classes as written, otherwise) |
pro_rata on costs | the loans follow the capex at their share; the equity pays the financing costs and the rest |
equity_first | the equity up to its commitments, the rest up to its amount (the uses less every other source), then the loans side by side |
debt_first (or back_ended) | the loans side by side up to their sizes, then the equity in order |
in_order [mezz, [senior, dfi], ordinary] | each in turn up to its size or commitment; sources in [ ] share what is left by their sizes (pari passu); the rest comes last, named or not |
One line, drawdown equity_first, says only the order. Rufineer checks that the sources equal the uses in every period, that the rest is never negative and that nothing is drawn past its size or commitment. A class of shares, as a line of its module, is what it subscribes each period (capex.pref_a); the sheet's lines are there to use: sources.uses, sources.senior_size, sources.total_uses. rfn conventions sources prints what Rufineer writes, as Rufineer, and a line in a module sources of your own replaces the one of that name.
The equity is sources.equity, the share capital. With one class of shares that pays the rest, that class is the share capital: one row, labelled as the class is. With several classes, each has its row and sources.equity adds them up.
Interest during construction
Each loan says what happens to its interest while the project is built (every period before operations.start; with no phase before operations, the first period of operations):
senior = loan(size: 65 % of costs,
interest_during_construction: rolled_up, // or: paid
rate: inputs.senior_rate, over: tenor,
repay: sculpted(dscr: inputs.target_dscr)) "Senior loan"rolled_up (the default) | paid | |
|---|---|---|
| What happens | each period's interest is added to the loan's balance; no cash moves | each period's interest is a use of funds, paid in cash from that period's drawdowns |
| Who pays for it | the loan, on top of its size: | every source, in the order of the drawdown: pro rata on uses (the default), the loan its share and the equity the rest |
| Uses of funds | sources.interest, Interest during construction, added to the loans | sources.senior_interest, Senior loan: interest during construction, under Financing costs |
| Sources of funds | sources.capitalised, Interest added to the loans: not a drawdown, so it is a row of its own and doesn't count against the size | nothing of its own: it is in the loan's and the equity's draws |
| Loop | none | with size: … of uses the size depends on the interest, which depends on the size: Rufineer writes the solve lines (Excel never iterates) |
| The loan's rows | senior.capitalising (the flag), senior.capitalised | none more |
Either way, interest is charged on the opening balance at the loan's rate: times each period's year fraction, so money drawn in a period earns interest from the next; there is no separate construction rate. With the full conventions it is added to the cost of the assets, which is depreciated, and kept off the income statement until operations start; the loan's financing_costs: expensed charges it to the income statement instead (with its fees), and financing_costs: amortised writes its fees off over its life.
Mind the gearing. Rolled up with size: 65 % of costs, the debt at COD is the size plus the interest: more than 65% of the total cost. For a gearing on the whole cost, interest included, write size: 65 % of uses, interest_during_construction: paid; the loan then pays 65% of every use, its own interest too.
Rolling up at other times is capitalise:, which a loan keeps in place of the default: a phase, a window, any flag, or always (Junior debt and refinancing):
shl = loan(by: shareholders, size: inputs.shl, rate: 12 %/y, over: operations,
repay: sweep, capitalise: always) "Shareholder loan" // PIK for its whole life
holiday = window(from: operations.start, for: 2y) // a PIK holiday after COD
mezz = loan(size: inputs.mezz, rate: 14 %/y, over: tenor, repay: equal,
capitalise: holiday) "Mezzanine"Planned, and refused with a message for now: some equity up front then pro rata (with a catch-up), an order chosen by a scenario switch, development reimbursed at financial close, a share of equity, a loan sized on cover, a reserve funded at COD by the sources, a facility earmarked for another use than VAT (vat_facility), grants, and funding at a date later in operations (an acquisition, an expansion).
By hand: funding
A funding block says who pays each period's uses of funds, and in what order.
funding uses {
first equity up to equity_commitment
then loan up to debt.facility
then overrun rest
}Read it top to bottom: equity pays until its commitment is used up, then the loan up to its facility, then whatever is left. The first step can instead share each period's need between sources:
funding uses {
first pro_rata {
senior share senior_share up to debt.senior_size | during construction
}
then pref_a up to inputs.pref_a | during construction
then ordinary rest
}A limit is read period by period, so it can be a commitment the Solve macro settles, like the sponsors' share of a total that includes interest during construction.
| during after a source limits when it pays: here nothing is lent or subscribed before financial close, so the ordinary equity pays the development costs.
On the sheet the block is Sources of funds: each source a line (capex.loan), then their total, capex.sources, which a check can compare with the uses. Under it, Drawn to date has the running total of each source with a limit. Put the uses above it as a block of their own, section "Uses of funds" with a line per use and their total, and the sheet reads uses, then sources, as a lender expects; the total the block funds is shown bold. Tutorials 2 and 3 show their funding both ways, declared and by hand, with the same results.
Circular references: solve
Project finance models have loops: interest during construction depends on the loan, and the loan on the cost it pays for. Rufineer never turns on iterative calculation. A loop compiles only when a solve line breaks it:
solve interest: USD = debt.interest | during construction "Interest during construction"The line becomes three rows: the calculated formula, the pasted values the model reads, and their difference (one cell each in column D when the formula is one number, like a debt size). The Solve macro copies one into the other until they agree, and a check fails until it has. A loop without a solve is a compile error that shows the whole loop, line by line. tolerance 10 USD, before or after the line's label, says how close is close enough (1 unit of money by default).
Meeting a target: seek
Goal Seek, written in the model: the input to change and the target to meet.
module bid "Bid" {
bid_toll = seek(inputs.heavy_vehicle_tariff, returns.equity_irr == inputs.target_irr) "Bid toll (HV)"
}The Solve macro changes the input on the Inputs sheet until the two sides are equal, settling the solve loops at each try: the bid tariff for a target IRR, the availability payment for an NPV of zero, the gearing for a minimum DSCR. The line shows the input as the macro left it (bid.bid_toll), with the gap to the target under it (bid.bid_toll.gap), and a check fails until the gap is closed: within 1 unit of money, or a millionth (0.0001% of an IRR).
- The input is one typed in an inputs block. One that grows (
| grow 4 %/y) has its base value moved; one a scenario picks, or a table's, can't be sought. - The target is
left == right, both single values: an IRR, an NPV, amin(…, during: …), an input. A row that changes over time is an error. - In the base case only. The sensitivity cases keep the value the base case found, as a bid does: a cost overrun doesn't change the toll you bid. Run sensitivities seeks in the base case first.
- Several seeks are met one after another, round after round, until every one is met. Targets that pull against each other (a toll for the IRR and the gearing for the DSCR, each moving the other) may not settle: the checks say so. Size the debt with a formula when you can (sculpting) and seek the one price.
The search is the secant method, with steps no bigger than the input until the target is bracketed, then kept inside the bracket. The preview and Export to Excel find the same value, so the exported workbook opens with the target met.
Statements
statement income "Income statement" | sum by year {
+ ops.revenue "Revenue"
- ops.opex "Operating costs"
= ebitda "EBITDA"
- accounts.depreciation "Depreciation"
= profit "Profit before tax"
}
statement balance "Balance sheet" | at year end {
section "Assets"
+ accounts.plant "Plant"
+ cash.bank "Cash"
= total_assets "Total assets"
}+ and - rows are added up; = name is a subtotal you can use elsewhere (income.ebitda). | sum by year adds each financial year's periods; | at year end (or at year start) takes the balance at the end of each year. section starts a new heading and a new running subtotal.
By half-year
Lenders often report twice a year. | sum by half gives two columns for each financial year, H1 and H2, whatever the model's columns are (monthly, quarterly or mixed); a balance in it is | at half end or | at half start:
statement lenders "Lenders' report" | sum by half {
+ waterfall.cfads "Cash flow available for debt service"
- waterfall.senior_service "Senior debt service"
= after_senior "Cash after senior debt"
section "Debt outstanding"
+ debt.senior | at half end "Senior loan"
}The halves follow the financial year (fiscal_year ends Mar: H1 is April to September), and the Timing sheet has a financial_half row, 1 or 2, that the SUMIFS read.
By quarter or by month
| sum by quarter gives a column for each quarter of the financial year (Q1 to Q4), and | sum by month one for each month; a balance in them is | at quarter end or | at month end (or start):
statement income "Income statement" | sum by month {
+ ops.revenue "Revenue"
- ops.opex "Operating costs"
= ebitda "EBITDA"
- accounts.depreciation "Depreciation"
= profit "Profit before tax"
}Every period must fit inside one of the columns, so a statement by month needs a monthly timeline, and one by quarter a monthly or quarterly one; on a model that is monthly while it is built and quarterly after, sum by quarter. The columns run from the first period's quarter (month) to the last's, and the sheet shows the day each one ends under the financial year and the quarter (Q) or month (M) of it. The Timing sheet has a financial_quarter (1 to 4) or financial_month (1 to 12) row that the SUMIFS read. The preview titles the columns Q3 2028, or by month with the month itself, Jul 2027.
A whole module
+ waterfall, with no sign of its own, label or |, brings in every line of the module that can be rolled up, under the module's own section headings: flows are added up, balances taken at the end of the year, half, quarter or month (and opening balances at its start). Rates, ratios and flags are left out, since adding them up means nothing.
statement annual "Waterfall by year" | sum by year {
+ waterfall
}Each row is named after the line it rolls up (annual.waterfall_senior_service), so a dashboard chart can use it.
Checks and alerts
check "Balance sheet balances" accounts.assets == accounts.funding tolerance 1 USD
check "Loan repaid" debt.loan.closing == 0 at debt.tenor.end tolerance 1 USD
alert "DSCR below covenant" debt.dscr >= 1.20 x | during debt.repayingA check is something that must be true: the master check on every sheet fails when one doesn't hold. An alert is something someone should know, such as a covenant breach: it is counted, but doesn't fail the model. at date tests one period, | during some periods, and tolerance says how far apart the two sides may be.
Some checks are added for you: every solve line (solved), every base_case line (stored), every seek line (target met), and every profile (adds up to 100%).
Dashboard and sensitivities
dashboard {
section "Returns"
returns.equity_irr "Equity IRR"
cash.min_dscr "Minimum DSCR"
chart line "DSCR" {
cash.dscr "DSCR"
}
}
sensitivities {
results {
returns.equity_irr "Equity IRR"
cash.min_dscr "Min DSCR"
}
case "P90 yield" { inputs.yield * 92 % }
case "Base rate +2%" { inputs.rate + 2 %/y }
case "Bidder B" { design = bid_b }
}The dashboard shows single values in sections and charts of lines (chart column or chart line). A line chart puts each period at its date and labels every year or two; a column chart of amounts shows one column per financial year, added up in a Chart data table under the charts. Each sensitivity case changes inputs (* to scale, + or - to shift) or uses another scenario (design = bid_b), and the Run sensitivities macro solves the model case by case and fills a table with the results.