Automating NSF budget justification line-item tables
A National Science Foundation (NSF) proposal is returned without review when its budget justification and its R&R Budget form disagree — a fringe figure that does not match the salary it is drawn on, an indirect total computed on the wrong base, a participant-support line quietly swept into the overhead calculation. The justification is prose, but every number in it must reconcile to the penny with the structured budget form, and doing that by hand across the eight NSF cost categories is where deadline-night arithmetic errors live. This page shows how to generate the justification line-item tables — senior personnel, other personnel, fringe, equipment, travel, participant support, other direct costs, and indirect costs — from structured budget data so the totals foot correctly and reconcile against the form by construction. It is the automation behind the parent budget justification format standards section, where a total that does not tie out is a compliance defect, not a rounding quibble.
Phase 1 — Model the NSF budget categories
The NSF R&R Budget lays cost out in lettered categories, and the justification must narrate each one in the same order. Model those categories explicitly rather than as loose dictionary keys, because the category an entry belongs to determines whether it is included in the indirect-cost base — the single rule that most often breaks reconciliation. Use Decimal for every dollar amount; floating-point money accumulates rounding error that shows up precisely as a cents-level mismatch against the form.
Implementation steps:
- Enumerate the categories. Represent NSF lines A through G as a fixed enumeration — senior personnel, other personnel, fringe benefits, equipment, travel, participant support, other direct costs — so a stray category name cannot silently create a phantom line.
- Tag each entry with its base membership. Record on every line whether it counts toward the modified total direct cost base that indirect costs are computed on; equipment and participant support are excluded.
- Keep amounts exact. Store money as
Decimal, quantized to cents, never asfloat. - Carry provenance. Keep the source of each figure so a justification sentence can be traced to its structured input, the same discipline applied when standardizing budget justification templates across agencies.
Pin the environment so a generated budget reproduces exactly on re-run:
python -m venv .venv
source .venv/bin/activate
pip install "pydantic>=2.6"
Model the category and the line entry. The in_indirect_base flag is derived from the category, not supplied by the caller, so equipment and participant support can never be mistakenly folded into overhead:
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, Field, field_validator
CENTS = Decimal("0.01")
class Category(str, Enum):
SENIOR_PERSONNEL = "A"
OTHER_PERSONNEL = "B"
FRINGE = "C"
EQUIPMENT = "D"
TRAVEL = "E"
PARTICIPANT_SUPPORT = "F"
OTHER_DIRECT = "G"
# Categories excluded from the modified total direct cost (indirect) base.
_EXCLUDED_FROM_BASE = {Category.EQUIPMENT, Category.PARTICIPANT_SUPPORT}
class LineItem(BaseModel):
"""One justification line, tied to an NSF budget category."""
category: Category
label: str
amount: Decimal
source: str = "" # provenance for the narrative
@field_validator("amount")
@classmethod
def _quantize(cls, v: Decimal) -> Decimal:
if v < 0:
raise ValueError("budget line amounts cannot be negative")
return v.quantize(CENTS) # exact cents, no float drift
@property
def in_indirect_base(self) -> bool:
return self.category not in _EXCLUDED_FROM_BASE
Phase 2 — Generate the tables and reconcile the totals
The generator groups line items by category, subtotals each, computes the modified direct base, applies the indirect rate, and sums to a grand total — the same numbers the R&R Budget form carries. Because indirect cost is derived from the base rather than entered independently, the justification and the form agree by construction instead of by luck. This is where the NSF Proposal Guide Taxonomy rules about which costs bear overhead become executable arithmetic.
Core transformation steps:
- Group and subtotal. Bucket line items by category and sum each bucket to a category subtotal.
- Compute the indirect base. Sum only the amounts whose category counts toward the modified total direct cost base.
- Apply the negotiated rate. Multiply the base by the institution’s indirect rate and quantize to cents.
- Foot the totals. Total direct plus indirect equals the grand total — the figure that must match the form.
from dataclasses import dataclass
@dataclass(slots=True)
class BudgetTotals:
direct_by_category: dict[Category, Decimal]
total_direct: Decimal
indirect_base: Decimal
indirect_cost: Decimal
grand_total: Decimal
def summarize(items: list[LineItem], indirect_rate: Decimal) -> BudgetTotals:
"""Subtotal each category and derive the reconciling totals."""
by_cat: dict[Category, Decimal] = {}
for item in items:
by_cat[item.category] = by_cat.get(item.category, Decimal("0")) + item.amount
total_direct = sum(by_cat.values(), Decimal("0")).quantize(CENTS)
# Base excludes equipment and participant support by category membership.
base = sum((i.amount for i in items if i.in_indirect_base), Decimal("0")).quantize(CENTS)
indirect = (base * indirect_rate).quantize(CENTS) # negotiated F&A rate
return BudgetTotals(
direct_by_category=by_cat,
total_direct=total_direct,
indirect_base=base,
indirect_cost=indirect,
grand_total=(total_direct + indirect).quantize(CENTS),
)
def render_justification(items: list[LineItem], rate: Decimal) -> str:
"""Emit the ordered line-item table as Markdown, category by category."""
totals = summarize(items, rate)
rows = ["| NSF line | Item | Amount |", "|---|---|---|"]
for cat in Category: # fixed NSF ordering
for item in (i for i in items if i.category == cat):
rows.append(f"| {cat.value} | {item.label} | ${item.amount:,.2f} |")
rows.append(f"| H | **Total direct costs** | **${totals.total_direct:,.2f}** |")
rows.append(f"| I | Indirect (on ${totals.indirect_base:,.2f}) | ${totals.indirect_cost:,.2f} |")
rows.append(f"| J | **Total** | **${totals.grand_total:,.2f}** |")
return "\n".join(rows)
The diagram traces structured budget data through categorization and subtotalling to the totals, and finally to the reconciliation check against the R&R Budget form.
Phase 3 — Edge cases and NSF cost rules
Four NSF-specific rules break a naive summer that treats every dollar the same. Each is a documented policy, and each maps to a branch in the model.
Participant support excluded from the indirect base. Line F participant support costs — trainee stipends, travel, and subsistence for conference or workshop attendees — are excluded from the modified total direct cost base on which indirect costs are computed. Fold them in and the overhead figure inflates and the form reconciliation fails. The in_indirect_base property enforces this by category so the exclusion is structural, not a step someone can forget.
Equipment excluded from the base. Line D equipment — items above the capitalization threshold with a useful life beyond one year — is likewise outside the indirect base. Supplies below that threshold belong in other direct costs and do bear overhead, so the classification of an item decides its base membership.
Senior personnel two-month rule. NSF will generally not fund more than two months of a senior person’s salary across all NSF awards in a year. The generator should surface requested senior-personnel months so a proposal exceeding two months is flagged for a documented justification rather than silently submitted.
Indirect rate application. Apply the institution’s negotiated rate to the correct base only, and quantize once at the end. Cost-sharing lines, when present, are tracked separately from the requested total and must not be summed into the grand total the form reports. The downstream form itself is populated from these same totals when populating SF424 (R&R) forms programmatically, so a discrepancy caught here never reaches the submission.
| Rule | Category / field | Effect on totals |
|---|---|---|
| Participant support excluded | Line F | Removed from indirect base, kept in direct total |
| Equipment excluded | Line D | Removed from indirect base, kept in direct total |
| Senior personnel 2-month cap | Line A months | Flag over-cap requests for justification |
| Cost sharing | Separate ledger | Tracked apart; never in the requested grand total |
Phase 4 — Validation that the totals reconcile
The one test that matters is reconciliation: the generated grand total must equal the direct total plus indirect computed on the correct base, to the cent. Test the exclusions explicitly, because a base that wrongly includes equipment or participant support is the failure that returns a proposal.
from decimal import Decimal
def _items() -> list[LineItem]:
return [
LineItem(category=Category.SENIOR_PERSONNEL, label="PI, 1.5 mo", amount=Decimal("18000")),
LineItem(category=Category.FRINGE, label="Fringe @ 30%", amount=Decimal("5400")),
LineItem(category=Category.EQUIPMENT, label="Confocal microscope", amount=Decimal("60000")),
LineItem(category=Category.PARTICIPANT_SUPPORT, label="Workshop stipends", amount=Decimal("12000")),
LineItem(category=Category.OTHER_DIRECT, label="Materials & supplies", amount=Decimal("4000")),
]
def test_base_excludes_equipment_and_participant_support() -> None:
totals = summarize(_items(), Decimal("0.55"))
# Base = 18000 + 5400 + 4000 = 27400; equipment and participant support excluded.
assert totals.indirect_base == Decimal("27400.00")
def test_grand_total_reconciles_to_the_cent() -> None:
totals = summarize(_items(), Decimal("0.55"))
assert totals.grand_total == (totals.total_direct + totals.indirect_cost)
assert totals.indirect_cost == (totals.indirect_base * Decimal("0.55")).quantize(CENTS)
def test_negative_line_amount_is_rejected() -> None:
try:
LineItem(category=Category.TRAVEL, label="Bad", amount=Decimal("-100"))
except ValueError:
pass
else:
raise AssertionError("a negative budget line must be rejected")
Close with an acceptance checklist a reviewer can run: confirm equipment and participant support are absent from the indirect base, that the grand total equals total direct plus indirect to the cent, that requested senior-personnel months over two are flagged, that cost sharing is reported outside the requested total, and that every generated figure ties back to a structured line item. Only when the tables reconcile by construction can the justification be trusted to match the form the panel actually reads.
Frequently asked questions
Why is participant support excluded from the indirect cost base?
NSF policy excludes Line F participant support costs — stipends, travel, and subsistence for attendees — from the modified total direct cost base that indirect costs are computed on. Including them inflates the overhead figure and breaks reconciliation with the R&R Budget form. Deriving base membership from the category, rather than trusting a caller-supplied flag, makes the exclusion structural so it cannot be forgotten.
Should I use float for budget amounts?
No. Use Decimal quantized to cents. Floating-point arithmetic accumulates tiny rounding errors that surface as exactly the cents-level mismatch between the justification and the form that gets a proposal returned. Quantize each line on validation and quantize the indirect calculation once at the end, and the totals foot precisely.
How do I keep the justification and the R&R Budget form in agreement?
Derive the indirect cost from the subtotalled base rather than entering it independently, and compute both the justification tables and the form totals from the same structured line items. The two then agree by construction. The reconciliation check compares the grand total against the form so any divergence is caught before submission rather than by a panel.
What does the senior-personnel two-month rule mean for automation?
NSF generally limits salary compensation for senior personnel to two months across all NSF awards in a year. The generator should surface requested senior-personnel months so a proposal exceeding two months is flagged for an explicit justification. It is a soft limit requiring documentation, not a hard rejection, so the right automated behavior is to raise it for review rather than to block it.
Related
- Standardizing budget justification templates across agencies — the cross-agency template layer these NSF tables plug into.
- Populating SF424 (R&R) forms programmatically — the form these reconciled totals populate.
- NSF Proposal Guide Taxonomy — the source of the NSF cost rules encoded as arithmetic here.
- Budget Justification Format Standards — the parent section governing how justifications are structured.
Up one level: Budget Justification Format Standards