Modeling lease renewal and escalation clauses as Python dataclasses

A validated lease payload — the kind produced by How to map FCC tower lease terms to JSON schemas — proves that a record’s fields are well-formed, but conformance alone does not answer the question a portfolio manager asks every quarter: when does site TWR-8842’s next option window open, and what will the rent be if the tenant exercises it? Answering that reliably means turning three loosely related lease concepts — a renewal option’s period and notice window, an escalation clause’s kind and rate, and the term’s anchor date — into typed objects that compute the same answer every time, for every lease, regardless of who reads the raw dictionary. This page builds directly on the controlled field vocabulary defined by Lease Taxonomy Standardization, converting a taxonomy entry’s renewal_option, notice window, and escalation_pct fields into Python dataclass objects that compute a deterministic renewal deadline and escalated rent, then seal the result with an audit hash.

Prerequisites & Context

This is a pure standard-library exercise: Python 3.10 or newer, dataclasses, datetime, enum, hashlib, json, and logging — no third-party packages. Before modeling a single lease, two things should already be in place:

  • A canonical field vocabulary. rent_amount_usd, escalation_pct, and lease_expiry need to mean the same measured quantity across every source lease, which is the job of Lease Taxonomy Standardization; the dataclasses here are the typed, computable expression of that vocabulary, not a replacement for it.
  • A downstream consumer for the computed values. The renewal deadline and escalated rent produced here feed the ledger built in Lease Payment & Escalation Tracking, which expects a concrete date and a concrete dollar figure, not a legal clause describing how to derive them.

Both sit inside the wider Telecom Tower Compliance Architecture & Data Mapping pipeline: this page is the modeling layer that turns validated lease fields into the deterministic dates and figures every downstream compliance check depends on.

Step-by-Step Implementation

Step 1 — Model RenewalOption, EscalationClause, and LeaseTerm as separate dataclasses. Keep each concept in its own type rather than flattening everything onto one lease object. A RenewalOption carries only period_years and notice_days; an EscalationClause carries a kind plus the rate fields it needs; LeaseTerm composes both alongside the anchor term_start date and rent_amount_usd.

python
@dataclass(frozen=True)
class RenewalOption:
    period_years: int
    notice_days: int

Step 2 — Compute the next renewal deadline from term start, option periods, and the notice window. Sum the period_years of every already-exercised option to find how far the current option’s term begins, add that option’s own period_years to find when it ends, then subtract notice_days to get the last date the tenant must act by.

python
def next_renewal_deadline(self, options_exercised: int = 0) -> date:
    elapsed = sum(o.period_years for o in self.renewal_options[:options_exercised])
    option = self.renewal_options[options_exercised]
    period_end = self.term_start.replace(year=self.term_start.year + elapsed + option.period_years)
    return period_end - timedelta(days=option.notice_days)

Step 3 — Resolve the escalation kind before computing the next rent. A raw lease clause reads as free text (“rent increases by the greater of 3% or CPI”), but once normalized, an escalation is exactly one of two computable kinds: FIXED, which adds a flat dollar amount, or PERCENTAGE, which multiplies by 1 + escalation_pct / 100. Branch on an EscalationKind enum rather than a string so an unresolved third value fails loudly instead of silently falling through to zero.

python
def next_rent(self, current_rent_usd: float) -> float:
    if self.kind is EscalationKind.PERCENTAGE:
        return round(current_rent_usd * (1 + self.escalation_pct / 100), 2)
    if self.kind is EscalationKind.FIXED:
        return round(current_rent_usd + self.fixed_amount_usd, 2)
    raise LeaseModelError(f"unresolved escalation kind {self.kind!r}")

Step 4 — Validate the notice-versus-period invariant at construction time. A notice_days value longer than the option period it belongs to describes an option a tenant could never actually exercise — the notice deadline would fall before the option period even opens. Enforce that in RenewalOption.__post_init__ so a malformed option raises the moment it is built, not months later when the scheduler tries to use it.

python
def __post_init__(self) -> None:
    if self.notice_days > self.period_years * 365:
        raise LeaseModelError(
            f"notice_days {self.notice_days} exceeds option period of {self.period_years}y"
        )

Step 5 — Seal the resolved term with a hashlib.sha256 audit hash. Once a LeaseTerm has produced a renewal deadline and an escalated rent, hash its canonical, sorted-key JSON serialization. The digest gives the Lease Payment & Escalation Tracking ledger a tamper-evident fingerprint of the exact inputs that produced a given deadline and rent figure, so a later dispute over “what did the model compute on ingestion day” has a verifiable answer.

Complete Runnable Example

The diagram below traces one lease — site TWR-8842, a five-year term starting 2021-06-01 at $2,400.00 monthly rent, with a five-year renewal option carrying a 180-day notice window and a 3.0% PERCENTAGE escalation — through to its computed renewal deadline and escalated rent step. The code underneath is self-contained, runs on Python 3.10+ with no third-party dependencies, and carries the three mandatory pieces for this codebase: structured logging, the custom LeaseModelError exception, and a hashlib.sha256 audit hash.

Lease timeline from term start to renewal deadline and escalated rent step A horizontal timeline of four boxes: term start (TWR-8842, 2021-06-01), option period (5 yr option, 180-day notice), renewal deadline (2025-12-03), and renewal effective (2026-06-01). The option-period box has a dashed downward branch to a notice-window annotation verifying notice_days does not exceed period_years. The renewal-effective box feeds down into a final escalated rent step box showing a PERCENTAGE escalation of 3.0 percent moving rent from $2,400.00 to $2,472.00. invariant Term start TWR-8842 2021-06-01 Option period period_years=5 notice_days=180 Notice window notice ≤ period Renewal deadline 2025-12-03 Renewal effective 2026-06-01 Escalated rent step PERCENTAGE +3.0% $2,400.00 → $2,472.00

Figure: a lease term flows from its anchor start date through the option period’s notice window to a computed renewal deadline, renewal effective date, and escalated rent step.

python
import hashlib
import json
import logging
from dataclasses import dataclass, field
from datetime import date, timedelta
from enum import Enum
from typing import List

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("lease_renewal")

class LeaseModelError(Exception):
    """Raised when a renewal option or escalation clause violates an invariant."""

class EscalationKind(str, Enum):
    FIXED = "FIXED"
    PERCENTAGE = "PERCENTAGE"

@dataclass(frozen=True)
class RenewalOption:
    period_years: int
    notice_days: int

    def __post_init__(self) -> None:
        if self.notice_days > self.period_years * 365:
            raise LeaseModelError(
                f"notice_days {self.notice_days} exceeds option period of {self.period_years}y"
            )

@dataclass(frozen=True)
class EscalationClause:
    kind: EscalationKind
    escalation_pct: float = 0.0
    fixed_amount_usd: float = 0.0

    def next_rent(self, current_rent_usd: float) -> float:
        if self.kind is EscalationKind.PERCENTAGE:
            return round(current_rent_usd * (1 + self.escalation_pct / 100), 2)
        if self.kind is EscalationKind.FIXED:
            return round(current_rent_usd + self.fixed_amount_usd, 2)
        raise LeaseModelError(f"unresolved escalation kind {self.kind!r}")

@dataclass(frozen=True)
class LeaseTerm:
    site_id: str
    term_start: date
    rent_amount_usd: float
    escalation: EscalationClause
    renewal_options: List[RenewalOption] = field(default_factory=list)

    def next_renewal_deadline(self, options_exercised: int = 0) -> date:
        if options_exercised >= len(self.renewal_options):
            raise LeaseModelError("no remaining renewal options")
        elapsed = sum(o.period_years for o in self.renewal_options[:options_exercised])
        option = self.renewal_options[options_exercised]
        period_end = self.term_start.replace(year=self.term_start.year + elapsed + option.period_years)
        return period_end - timedelta(days=option.notice_days)

    def escalated_rent(self, options_exercised: int) -> float:
        rent = self.rent_amount_usd
        for _ in range(options_exercised + 1):
            rent = self.escalation.next_rent(rent)
        return rent

    def seal(self) -> str:
        payload = {"site_id": self.site_id, "term_start": self.term_start.isoformat(),
                   "rent_amount_usd": self.rent_amount_usd, "escalation_kind": self.escalation.kind.value}
        canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
        return hashlib.sha256(canonical.encode("utf-8")).hexdigest()

if __name__ == "__main__":
    lease = LeaseTerm(
        site_id="TWR-8842",
        term_start=date(2021, 6, 1),
        rent_amount_usd=2400.00,
        escalation=EscalationClause(kind=EscalationKind.PERCENTAGE, escalation_pct=3.0),
        renewal_options=[RenewalOption(period_years=5, notice_days=180)],
    )
    deadline = lease.next_renewal_deadline(options_exercised=0)
    rent = lease.escalated_rent(options_exercised=0)
    digest = lease.seal()
    log.info("site=%s deadline=%s rent=%.2f audit=%s", lease.site_id, deadline, rent, digest[:16])
    print(json.dumps({"next_renewal_deadline": deadline.isoformat(),
                       "escalated_rent_usd": rent, "audit_id": digest[:16]}, indent=2))

Verification & Expected Output

Save the block as lease_renewal.py and run python3 lease_renewal.py. With the TWR-8842 sample above, the option period ends five years after term_start — 2026-06-01 — and the 180-day notice window pulls the deadline back to 2025-12-03; the PERCENTAGE clause takes $2,400.00 to $2,472.00 at 3.0%. You should see exactly:

text
INFO site=TWR-8842 deadline=2025-12-03 rent=2472.00 audit=... (16-char digest)
{
  "next_renewal_deadline": "2025-12-03",
  "escalated_rent_usd": 2472.0,
  "audit_id": "..."
}

The audit_id is deterministic for these exact field values — the same site_id, term_start, rent_amount_usd, and escalation kind always produce the same digest, because seal() hashes a canonical sorted-key serialization rather than an object identity. If the digest changes between runs on unchanged input, something upstream is mutating the lease before it reaches seal(). To see the invariant check fire, construct a RenewalOption with a notice window longer than its own period — RenewalOption(period_years=1, notice_days=400) — and LeaseModelError raises immediately from __post_init__ with notice_days 400 exceeds option period of 1y, before the option ever reaches a LeaseTerm. To see an unresolved escalation kind fail loudly, construct an EscalationClause by bypassing the enum with a raw string cast that doesn’t match either member; next_rent() falls through both branches and raises unresolved escalation kind rather than silently returning the unescalated rent.

Gotchas & Edge Cases

  • Multiple stacked renewal options compound, they don’t reset. A lease with three five-year options exercised in sequence needs escalated_rent() called with the correct options_exercised count each time, because the method reapplies the escalation clause once per exercised option from the base rent_amount_usd. Passing the wrong count silently under- or over-escalates the rent rather than raising, since there is no invariant that ties options_exercised to how many options have actually been exercised in reality — that bookkeeping belongs to the caller, typically the ledger in Lease Payment & Escalation Tracking.
  • date.replace(year=...) breaks on a February 29 anchor. A term_start of February 29 on a leap year raises ValueError when next_renewal_deadline() tries to replace the year with a non-leap target year, because that calendar date does not exist. Normalize leap-day anchors to February 28 (or March 1, per the lease’s own governing-law convention) during ingestion, before the date ever reaches a LeaseTerm, rather than trying to catch the exception at computation time.
  • A zero-notice option is legal but dangerous. RenewalOption(period_years=5, notice_days=0) passes the notice_days > period_years * 365 invariant cleanly — zero is never greater than a positive bound — but it describes an option that must be exercised on the exact day the current term ends, with no lead time for the ledger to flag it. The invariant check in Step 4 guards against an impossible option; it does not guard against a merely reckless one, so a zero or near-zero notice_days value is worth a separate operational warning rather than a hard rejection.

FAQ

Why frozen dataclasses instead of plain mutable ones?

A RenewalOption, EscalationClause, or LeaseTerm represents a fact recorded in a signed lease document — the period, the notice window, the escalation rate — and none of those facts should change once modeled. @dataclass(frozen=True) raises on any attempted attribute assignment after construction, which turns an accidental mutation (a script that “corrects” a rent figure in place) into an immediate exception instead of a silently altered audit trail. It also means two LeaseTerm instances with identical field values are safe to compare and hash as equivalent.

Why validate the notice-versus-period invariant in `__post_init__` instead of at renewal time?

Checking in RenewalOption.__post_init__ means a malformed option can never exist as a valid Python object in the first place — it fails at construction, when the bad data is still close to its source and easy to trace back to a specific lease. Deferring the check to next_renewal_deadline() would let an invalid option sit silently inside a LeaseTerm for months, passing through every other operation, until the one code path that happens to call the renewal calculation finally surfaces the problem — by which point the original source record may be harder to reconcile.

How does this differ from the JSON Schema validation in the sibling guide?

How to map FCC tower lease terms to JSON schemas validates that a raw lease dictionary’s fields have the right types, formats, and bounds — it is a shape check performed once, at ingestion. The dataclasses here go a step further: they are computable objects that derive new facts, a renewal deadline and an escalated rent, from validated fields, and they enforce a cross-field invariant (notice window versus option period) that a flat JSON Schema document cannot express as cleanly as a Python __post_init__ check.

Related pages