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:

  1. 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.
  2. 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.
  3. Keep amounts exact. Store money as Decimal, quantized to cents, never as float.
  4. 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:

bash
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:

python
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:

  1. Group and subtotal. Bucket line items by category and sum each bucket to a category subtotal.
  2. Compute the indirect base. Sum only the amounts whose category counts toward the modified total direct cost base.
  3. Apply the negotiated rate. Multiply the base by the institution’s indirect rate and quantize to cents.
  4. Foot the totals. Total direct plus indirect equals the grand total — the figure that must match the form.
python
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.

Generating NSF budget justification tables and reconciling them to the R&R Budget form Structured budget data flows into a categorize stage that sorts entries into NSF lines A through G, then into a line-item tables stage that subtotals each category. That feeds a totals stage that sums total direct costs and applies the indirect rate. The totals reach a reconciliation decision that compares them against the R&R Budget form: when they match, the justification tables are emitted, drawn as a solid highlighted box; when they differ, a discrepancy is flagged for review. Budget data structured input Categorize into lines A–G Line-item tables subtotal each category Sum direct + indirect Totals = R&R form? Emit justification tables Discrepancy flag for review reconciles differs
Deriving indirect cost from the subtotalled base makes the justification tables reconcile with the R&R Budget form by construction rather than by manual re-entry.

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.

python
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.

Up one level: Budget Justification Format Standards