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.
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.
# 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.
- Assign a canonical element type. Every line item carries a
CostElementTypedrawn from a closed enumeration, so classification is total — there is no “unknown” that silently defaults into the base. - 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.
- 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.
- 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.
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.
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.
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.
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
Decimalcontext 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.
Related
- Parent: Core Architecture & Compliance Mapping
- Automating the 10% de minimis indirect cost rate — the fixed-rate walkthrough this module applies
- Applying a negotiated indirect cost rate agreement in Python — NICRA effective-date windows and ceiling sourcing
- IRS 990 Data Schema Mapping — where the recovered indirect amount lands in functional-expense reporting
- Audit Trail & Evidence Automation — retention and evidence for the calculation record
- Field Mapping & Normalization — the upstream cross-pillar stage that produces canonical,
Decimal-typed cost elements