Indirect Cost Rate Automation

Automate 2 CFR 200.414 indirect (F&A) cost recovery in Python: classify cost elements, build the 2 CFR 200.1 Modified Total Direct Cost base with required exclusions, apply the 10% de minimis or a negotiated NICRA rate, and reject any rate above the ceiling — Decimal money math, structured audit logging.

This guide is part of the Core Architecture & Compliance Mapping reference. Indirect Cost Rate Automation is the deterministic calculation module that classifies a grant’s direct-cost line items, assembles the Modified Total Direct Cost (MTDC) base defined at 2 CFR §200.1, applies an indirect facilities-and-administrative (F&A) rate under 2 CFR §200.414, and emits either a validated indirect-recovery figure or a deterministic rejection.

The scope is confined to the arithmetic of indirect recovery: taking already-normalized direct-cost elements, deciding which belong in the MTDC base, and multiplying that base by an authorized rate. This module does not acquire or parse budgets, negotiate rates with a cognizant agency, post to a general ledger, or file a return. It consumes canonical cost elements produced upstream by Field Mapping & Normalization and evaluated against funder obligations in Grantor-Specific Rule Taxonomies. The two rate-selection walkthroughs live in child guides: the 10% de minimis indirect cost rate and applying a negotiated indirect cost rate agreement in Python. Downstream, the recovered indirect amount flows into functional-expense reporting in IRS 990 Data Schema Mapping, and the calculation evidence is preserved by Audit Trail & Evidence Automation. Every path here terminates at exactly one of two outcomes: a computed indirect-recovery figure with its base breakdown, or a deterministic ceiling/consistency rejection.

Indirect cost rate calculation flow: direct costs pass through the MTDC exclusion filter into the MTDC base, then multiply by an authorized rate to yield indirect recovery, with a ceiling-rejection branch Normalized direct-cost elements enter an MTDC exclusion filter that removes equipment and capital expenditures, rental costs, tuition remission, scholarships, participant support costs, and the portion of each subaward above fifty thousand dollars. The surviving amounts form the MTDC base. The base is multiplied by an authorized indirect rate — either the ten percent de minimis rate or a negotiated NICRA rate — to produce indirect recovery, which hands off to IRS 990 functional-expense reporting. A rate that exceeds the negotiated or ceiling rate drops to a RATE_REJECTED terminal instead of multiplying. Direct costs → MTDC exclusion filter → MTDC base → × authorized rate → indirect recovery Direct-cost elements salaries · supplies · travel equipment · subawards MTDC exclusion filter drop equipment · rental · tuition scholarships · participant support MTDC base 2 CFR §200.1 subaward capped at $50,000 Apply rate 10% de minimis or negotiated NICRA Indirect recovery base × rate (Decimal) → IRS 990 functional expense Excluded from base capital · rental · participant support not indirect-bearing RATE_REJECTED requested rate > negotiated / ceiling ERR_RATE_EXCEEDS_CEILING

Prerequisites

This module targets Python 3.11+ and deliberately depends on nothing beyond the standard library for the calculation core — decimal, dataclasses, enum, and logging. Money math that touches federal funds must never run through binary floating point; a rate applied with float will drift by fractions of a cent and desynchronize from the ledger over a multi-year period of performance. Pin the surrounding validation and serialization libraries so a dependency bump cannot silently change coercion behavior across audit re-runs.

text
# requirements.txt — pinned for reproducible indirect-cost calculation
pydantic==2.9.2      # rate-agreement contract validation only
python-dateutil==2.9.0.post0   # NICRA effective-date windows
# calculation core uses the stdlib: decimal, dataclasses, enum, logging

The calculation core has no third-party runtime dependency, which keeps the audited surface small. Environment variables consumed by this module:

Variable Purpose Example
INDIRECT_RATE_MODE Selects de_minimis or nicra rate source nicra
INDIRECT_SUBAWARD_CAP MTDC per-subaward inclusion ceiling in whole dollars 50000
INDIRECT_DE_MINIMIS_RATE The fixed de minimis rate expressed as a decimal string 0.10
INDIRECT_ROUNDING decimal rounding mode for the final recovery figure ROUND_HALF_UP

Upstream stage dependency: cost elements are only eligible for classification once they have been reconciled to canonical names and coerced to Decimal amounts by Field Mapping & Normalization. This module never re-parses a funder document; it operates strictly on typed, already-validated line items. The two rate sources it applies — the fixed de minimis rate and a negotiated agreement — are set up in the child guides and are treated here as an authorized input, not a decision this module makes.

Cost-Element Classification

Indirect recovery is only as defensible as the base it multiplies. The federal definition at 2 CFR §200.1 builds Modified Total Direct Cost from all direct salaries and wages, applicable fringe benefits, materials and supplies, services, travel, and up to the first $50,000 of each subaward — and then removes a fixed set of elements that do not bear their proportionate share of indirect costs. Getting one element on the wrong side of that line is the single most common source of a disallowed cost during a Single Audit.

The exclusions are exhaustive and specific. MTDC excludes equipment and other capital expenditures, rental costs, tuition remission, scholarships and fellowships, participant support costs, and the portion of each subaward (subgrant or subcontract) in excess of $50,000. Each excluded element is still a real, allowable direct cost — it simply does not enter the indirect base. Classification is therefore a routing decision, not an allowability decision: allowability was already settled upstream.

  1. Assign a canonical element type. Every line item carries a CostElementType drawn from a closed enumeration, so classification is total — there is no “unknown” that silently defaults into the base.
  2. Route on the exclusion set. Elements whose type is in the MTDC exclusion set are recorded with a rationale and kept out of the base; everything else is included in full.
  3. Cap subawards, do not drop them. A subaward contributes its first $50,000 to the base and excludes only the remainder — a partial inclusion, distinct from the all-or-nothing exclusions.
  4. Preserve the breakdown. The base is emitted alongside a per-element ledger so a reviewer can reconstruct exactly which dollars were counted and why.

Core Implementation

The IndirectCostCalculator below classifies each cost element, computes the MTDC base with Decimal arithmetic, applies an authorized rate, and refuses any rate above the negotiated or statutory ceiling. Errors route through a structured exception carrying a machine-readable code rather than surfacing as a bare exception or a silent zero.

python
import logging
from dataclasses import dataclass, field
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from typing import Dict, List

logger = logging.getLogger("indirect_cost.calculator")

CENTS = Decimal("0.01")
SUBAWARD_CAP = Decimal("50000")


class CostElementType(str, Enum):
    SALARIES_WAGES = "salaries_wages"
    FRINGE_BENEFITS = "fringe_benefits"
    MATERIALS_SUPPLIES = "materials_supplies"
    TRAVEL = "travel"
    CONTRACTUAL_SERVICES = "contractual_services"
    EQUIPMENT = "equipment"                 # excluded: capital expenditure
    RENTAL_COSTS = "rental_costs"           # excluded
    TUITION_REMISSION = "tuition_remission" # excluded
    SCHOLARSHIPS = "scholarships"           # excluded (incl. fellowships)
    PARTICIPANT_SUPPORT = "participant_support"  # excluded
    SUBAWARD = "subaward"                   # first $50,000 included, remainder excluded


# 2 CFR §200.1 — elements wholly removed from the MTDC base.
MTDC_EXCLUSIONS: frozenset = frozenset({
    CostElementType.EQUIPMENT,
    CostElementType.RENTAL_COSTS,
    CostElementType.TUITION_REMISSION,
    CostElementType.SCHOLARSHIPS,
    CostElementType.PARTICIPANT_SUPPORT,
})


class IndirectCostError(Exception):
    """Structured calculation failure carrying a machine-readable reason code."""

    def __init__(self, code: str, detail: str) -> None:
        self.code = code
        self.detail = detail
        super().__init__(f"{code}: {detail}")


@dataclass(frozen=True)
class CostElement:
    label: str
    element_type: CostElementType
    amount: Decimal


@dataclass
class MTDCResult:
    mtdc_base: Decimal
    included: List[Dict[str, str]] = field(default_factory=list)
    excluded: List[Dict[str, str]] = field(default_factory=list)


@dataclass
class IndirectResult:
    mtdc_base: Decimal
    applied_rate: Decimal
    indirect_recovery: Decimal
    breakdown: MTDCResult


class IndirectCostCalculator:
    """Compute 2 CFR §200.414 indirect recovery on a 2 CFR §200.1 MTDC base."""

    def __init__(self, ceiling_rate: Decimal, subaward_cap: Decimal = SUBAWARD_CAP) -> None:
        if ceiling_rate < Decimal("0"):
            raise IndirectCostError("ERR_INVALID_CEILING", "ceiling rate must be non-negative")
        self.ceiling_rate = ceiling_rate
        self.subaward_cap = subaward_cap

    def _classify(self, element: CostElement) -> Decimal:
        """Return the portion of one element that enters the MTDC base."""
        if element.amount < Decimal("0"):
            raise IndirectCostError("ERR_NEGATIVE_AMOUNT", f"{element.label} is negative")
        if element.element_type in MTDC_EXCLUSIONS:
            return Decimal("0")
        if element.element_type is CostElementType.SUBAWARD:
            # Only the first $50,000 of each subaward is indirect-bearing.
            return min(element.amount, self.subaward_cap)
        return element.amount

    def compute_mtdc(self, elements: List[CostElement]) -> MTDCResult:
        base = Decimal("0")
        result = MTDCResult(mtdc_base=Decimal("0"))
        for element in elements:
            included_amount = self._classify(element)
            base += included_amount
            record = {
                "label": element.label,
                "type": element.element_type.value,
                "amount": str(element.amount),
                "included_in_base": str(included_amount),
            }
            if included_amount == Decimal("0"):
                record["rationale"] = "excluded from MTDC per 2 CFR §200.1"
                result.excluded.append(record)
            else:
                if included_amount < element.amount:
                    record["rationale"] = "subaward capped at $50,000 per 2 CFR §200.1"
                result.included.append(record)
        result.mtdc_base = base.quantize(CENTS, rounding=ROUND_HALF_UP)
        logger.info(
            "mtdc_computed | base=%s | included=%d | excluded=%d | reg=2_CFR_200.1",
            result.mtdc_base, len(result.included), len(result.excluded),
        )
        return result

    def apply_rate(self, elements: List[CostElement], requested_rate: Decimal) -> IndirectResult:
        if requested_rate < Decimal("0"):
            raise IndirectCostError("ERR_NEGATIVE_RATE", "indirect rate must be non-negative")
        if requested_rate > self.ceiling_rate:
            logger.warning(
                "rate_rejected | requested=%s | ceiling=%s | reg=2_CFR_200.414",
                requested_rate, self.ceiling_rate,
            )
            raise IndirectCostError(
                "ERR_RATE_EXCEEDS_CEILING",
                f"requested {requested_rate} exceeds ceiling {self.ceiling_rate}",
            )
        breakdown = self.compute_mtdc(elements)
        recovery = (breakdown.mtdc_base * requested_rate).quantize(CENTS, rounding=ROUND_HALF_UP)
        logger.info(
            "indirect_computed | base=%s | rate=%s | recovery=%s | reg=2_CFR_200.414",
            breakdown.mtdc_base, requested_rate, recovery,
        )
        return IndirectResult(
            mtdc_base=breakdown.mtdc_base,
            applied_rate=requested_rate,
            indirect_recovery=recovery,
            breakdown=breakdown,
        )

Two invariants make the module auditable. The rate check runs before any base arithmetic, so a rejected rate never produces a partial number that could leak into a report. And the entire calculation stays in Decimal — the base, the rate, and the product — with a single quantize to cents at each emission boundary, so the recovered figure reconciles exactly against a manually recomputed ledger.

Schema Contract: Cost-Element Classification

The classification table is the contract between upstream normalization and this module. Each canonical element type maps to a fixed inclusion rule and a rationale traceable to the regulation. Upstream mappers must coerce every line item to one of these types; an element that cannot be classified is a schema violation, not a default-into-base.

Cost element In MTDC base? Rationale (2 CFR §200.1 / §200.414)
Salaries & wages (direct) Yes Direct salaries and wages are the core indirect-bearing base
Fringe benefits Yes Applicable employee fringe benefits are included in MTDC
Materials & supplies Yes Direct materials and supplies bear indirect cost
Travel Yes Direct travel is part of MTDC
Contractual services Yes Direct services are included (distinct from a subaward)
Equipment / capital expenditure No Explicitly excluded from MTDC as a capital expenditure
Rental costs No Explicitly excluded from MTDC
Tuition remission No Explicitly excluded from MTDC
Scholarships & fellowships No Explicitly excluded from MTDC
Participant support costs No Explicitly excluded from MTDC
Subaward, first $50,000 Yes The first $50,000 of each subaward is indirect-bearing
Subaward, amount over $50,000 No The portion of each subaward above $50,000 is excluded

The per-subaward cap is applied to each subaward independently, not to the aggregate of all subawards — three $40,000 subawards contribute the full $120,000 to the base, while one $120,000 subaward contributes only $50,000. Encoding the cap as a per-element rule in _classify keeps that distinction correct by construction.

python
from decimal import Decimal

# Canonical inbound record shape from Field Mapping & Normalization.
RAW_ELEMENT_ALIASES: dict = {
    "Personnel": "salaries_wages",
    "Salaries": "salaries_wages",
    "Fringe": "fringe_benefits",
    "Supplies": "materials_supplies",
    "Equipment": "equipment",
    "Capital Outlay": "equipment",
    "Participant Costs": "participant_support",
    "Stipends to Trainees": "participant_support",
    "Consortium/Subaward": "subaward",
}


def coerce_element(raw_label: str, raw_amount: str) -> CostElement:
    """Map a funder label to a canonical CostElementType with a Decimal amount."""
    canonical = RAW_ELEMENT_ALIASES.get(raw_label)
    if canonical is None:
        raise IndirectCostError("ERR_UNCLASSIFIED_ELEMENT", f"no mapping for {raw_label!r}")
    cleaned = raw_amount.replace("$", "").replace(",", "").strip()
    return CostElement(
        label=raw_label,
        element_type=CostElementType(canonical),
        amount=Decimal(cleaned),
    )

Validation & Testing

Determinism in money math is only credible if it is asserted against known-answer cases. The following pytest fixtures pin the exclusion behavior, the subaward cap, and the ceiling rejection, and check that the recovered figure is exact to the cent.

python
from decimal import Decimal

import pytest

from indirect_cost.calculator import (
    CostElement, CostElementType, IndirectCostCalculator, IndirectCostError,
)


@pytest.fixture
def elements() -> list:
    return [
        CostElement("Personnel", CostElementType.SALARIES_WAGES, Decimal("200000")),
        CostElement("Fringe", CostElementType.FRINGE_BENEFITS, Decimal("60000")),
        CostElement("Equipment", CostElementType.EQUIPMENT, Decimal("85000")),
        CostElement("Participant Costs", CostElementType.PARTICIPANT_SUPPORT, Decimal("40000")),
        CostElement("Consortium", CostElementType.SUBAWARD, Decimal("120000")),
    ]


def test_exclusions_removed_from_base(elements: list) -> None:
    calc = IndirectCostCalculator(ceiling_rate=Decimal("0.50"))
    mtdc = calc.compute_mtdc(elements)
    # 200000 + 60000 + first 50000 of the subaward; equipment & participant support excluded.
    assert mtdc.mtdc_base == Decimal("310000.00")


def test_subaward_capped_not_dropped(elements: list) -> None:
    calc = IndirectCostCalculator(ceiling_rate=Decimal("0.50"))
    mtdc = calc.compute_mtdc(elements)
    subaward = next(r for r in (mtdc.included + mtdc.excluded) if r["type"] == "subaward")
    assert subaward["included_in_base"] == "50000"


def test_de_minimis_recovery_is_exact(elements: list) -> None:
    calc = IndirectCostCalculator(ceiling_rate=Decimal("0.10"))
    result = calc.apply_rate(elements, requested_rate=Decimal("0.10"))
    assert result.indirect_recovery == Decimal("31000.00")


def test_rate_above_ceiling_rejected(elements: list) -> None:
    calc = IndirectCostCalculator(ceiling_rate=Decimal("0.155"))
    with pytest.raises(IndirectCostError) as exc:
        calc.apply_rate(elements, requested_rate=Decimal("0.20"))
    assert exc.value.code == "ERR_RATE_EXCEEDS_CEILING"

Property-based tests with hypothesis guard the arithmetic invariant that the recovered amount can never exceed base × ceiling, regardless of the mix of elements — a stronger guarantee than any handful of examples.

python
from decimal import Decimal

from hypothesis import given, strategies as st

from indirect_cost.calculator import CostElement, CostElementType, IndirectCostCalculator

money = st.decimals(min_value=0, max_value=1_000_000, places=2, allow_nan=False, allow_infinity=False)


@given(salary=money, supplies=money)
def test_recovery_never_exceeds_base_times_ceiling(salary: Decimal, supplies: Decimal) -> None:
    calc = IndirectCostCalculator(ceiling_rate=Decimal("0.10"))
    elements = [
        CostElement("Personnel", CostElementType.SALARIES_WAGES, salary),
        CostElement("Supplies", CostElementType.MATERIALS_SUPPLIES, supplies),
    ]
    result = calc.apply_rate(elements, requested_rate=Decimal("0.10"))
    assert result.indirect_recovery <= (result.mtdc_base * Decimal("0.10")).quantize(Decimal("0.01"))

Performance & Scale Considerations

Indirect calculation is arithmetic over dozens to low hundreds of line items per award — it is not a throughput problem. Optimize for auditability and reproducibility, not speed.

  • Memory ceiling. A single award’s element list is trivially small; the only unbounded structure is the per-element breakdown ledger. For a portfolio recalculation across thousands of awards, stream awards one at a time rather than materializing every breakdown in memory at once.
  • Batch sizing. When recomputing indirect recovery across a full grant portfolio (for example, after a NICRA rate change), process awards in batches of 100–200 so the audit log flushes in coherent units and a single malformed award never fails an entire run.
  • Concurrency. The calculator is stateless and its inputs are immutable frozen dataclasses, so awards parallelize cleanly across a bounded worker pool governed by Async Batch Processing Pipelines. Keep Decimal context configuration per-process to avoid cross-thread context bleed.
  • Rounding discipline. Quantize only at emission boundaries — the base and the final recovery — never mid-calculation. Repeated intermediate rounding accumulates cent-level drift across a large portfolio and breaks ledger reconciliation.

Failure Modes & Troubleshooting

Every failure resolves to a machine-readable reason code, never a silent zero or a partial figure. A sustained shift in one code is the primary operational signal: a surge of ERR_UNCLASSIFIED_ELEMENT almost always means a funder introduced a new budget-category label that must be added to the alias contract upstream, not worked around here.

Error code Root cause Remediation
ERR_UNCLASSIFIED_ELEMENT Funder label has no canonical mapping Extend the alias table in Field Mapping & Normalization; never default an unknown element into the base
ERR_RATE_EXCEEDS_CEILING Requested rate above the negotiated/statutory ceiling Reconcile against the NICRA in the negotiated rate guide; use only an authorized rate
ERR_NEGATIVE_AMOUNT A credit or reversal reached classification as a raw negative Resolve credits upstream; MTDC line items must be non-negative direct costs
ERR_NEGATIVE_RATE Corrupt or mis-parsed rate string Validate the rate contract before calling apply_rate; expect a Decimal in [0, ceiling]
ERR_INVALID_CEILING Calculator constructed with a negative ceiling Fix the NICRA/de minimis configuration; the ceiling is a non-negative Decimal
Subaward over-included Cap applied to aggregate rather than per-subaward Keep each subaward a distinct CostElement; the $50,000 cap is per award instrument

Consistency is not enforced here in isolation. 2 CFR §200.403 requires that a cost be treated consistently as either direct or indirect across all awards; classification drift for the same cost between two grants is a compliance defect that the Grantor-Specific Rule Taxonomies layer is responsible for catching across the portfolio.

Compliance Alignment

This module satisfies specific federal cost-principle obligations rather than generic “compliance.” Every computed recovery figure and every rejection is auditable evidence, anchored to the element breakdown that produced it.

Calculation gate Regulatory mapping Audit artifact
MTDC base assembly with required exclusions 2 CFR §200.1 (Modified Total Direct Cost definition) Per-element inclusion/exclusion ledger with rationale
Per-subaward $50,000 inclusion cap 2 CFR §200.1 (subaward exclusion above $50,000); 2 CFR §200.68 (subaward definition) Capped-inclusion record showing the $50,000 boundary per instrument
Rate application and ceiling rejection 2 CFR §200.414 (indirect F&A costs; de minimis rate) Decimal recovery figure or ERR_RATE_EXCEEDS_CEILING manifest
Consistent direct/indirect treatment 2 CFR §200.403 (factors affecting allowability; consistency) Stable CostElementType assignment per canonical cost
Exact money arithmetic 2 CFR §200.414; 2 CFR §200.302 (financial management) Decimal base, rate, and product with single-boundary quantize

A compliance officer can reconstruct any recovery figure by replaying the element breakdown against the applied rate — the base, the exclusions, and the cap are all recorded, not just the final number. The de minimis rate mechanics are detailed in automating the 10% de minimis indirect cost rate, and negotiated-rate application in applying a negotiated indirect cost rate agreement in Python. The recovery figure and its breakdown are retained as audit evidence per the schedules in Audit Trail & Evidence Automation, and roll up into functional-expense reporting through IRS 990 Data Schema Mapping.