Computing IATSE Golden-Hours Overtime in Python

Under the International Alliance of Theatrical Stage Employees (IATSE) collective bargaining agreements, a crew member’s hours do not accrue at one rate across a long day. The first block is straight time; past a threshold each hour is time-and-a-half; past a second threshold it is double time; and past a further threshold — commonly the fifteenth worked hour on a five-day week — every additional hour becomes golden time, paid at a multiplied rate that is the single most expensive figure on the call sheet. The edge case this page solves is precise: given a timezone-aware call and wrap that may straddle a Daylight Saving Time boundary, split the worked span into straight, time-and-a-half, double, and golden bands to the minute, price each band in Decimal from an agreement-governed rate table, and emit an audit record a completion guarantor can reproduce months later. Naive datetime subtraction and floating-point money both fail under real shooting conditions, so this walkthrough builds the tiering as a pure function over aware instants.

Prerequisites and Context

This page extends the parent engine documented in IATSE Crew Rule Validation: Meal Penalties, Turnaround, and Golden Hours in Python; it assumes the call and wrap instants have already been cost-coded and typed upstream, and focuses entirely on the overtime tiering and the audit trail around a single crew day. It targets Python 3.11+ for the standard-library zoneinfo module, and uses the same deliberate dependency set as the rest of Guild Compliance & Rule Validation Automation: decimal for every monetary value, zoneinfo for IANA timezone resolution rather than a bare UTC offset, hashlib for the payload fingerprint, and Pydantic v2 (model_validate, field_validator) for the boundary schema. Records reach this function already parsed by the upstream Cost Ingestion & Data Parsing Workflows subsystem, and each hour keys to one budget line through Cost Code Standardization. The clause that matters is the overtime schedule of the applicable IATSE agreement: because the tier boundaries and multipliers vary by agreement, tier, and amendment — and golden time itself is sometimes double, sometimes double-and-a-half or more — treat every threshold and multiplier as a configurable rule parameter, never hardcoded law.

Step-by-Step Implementation

Validation begins at the boundary. A Pydantic v2 model rejects any instant that is not timezone-aware and any wrap that does not follow its call, because a naive datetime is the single largest source of a mis-placed tier boundary. The worked span is offset-invariant, so it is computed on normalized UTC values while display stays shoot-local. The span is then walked band by band: each cumulative threshold consumes a slice of hours at its multiplier, and whatever remains past the golden threshold is priced at the golden rate.

from __future__ import annotations

import hashlib
import json
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from zoneinfo import ZoneInfo

from pydantic import BaseModel, ConfigDict, Field, field_validator

# Rule parameters — resolve from the applicable IATSE agreement, not hardcoded law.
# Cumulative worked-hour boundaries and the multiplier that applies ABOVE each.
TIER_BOUNDARIES = [
    (Decimal("0"), Decimal("1.0")),    # straight time from hour 0
    (Decimal("8"), Decimal("1.5")),    # time-and-a-half after 8h
    (Decimal("12"), Decimal("2.0")),   # double time after 12h
    (Decimal("15"), Decimal("2.5")),   # golden time after 15h
]
GOLDEN_THRESHOLD = Decimal("15")


class OvertimeRecord(BaseModel):
    """One crew member's worked span for a single shoot day."""
    model_config = ConfigDict(frozen=True)

    crew_id: str
    hourly_rate: Decimal = Field(gt=Decimal("0"))
    shoot_tz: str                     # IANA id, e.g. "America/Los_Angeles"
    call: datetime                    # timezone-aware call
    wrap: datetime                    # timezone-aware wrap

    @field_validator("hourly_rate", mode="before")
    @classmethod
    def _reject_float(cls, v: object) -> Decimal:
        if isinstance(v, float):
            raise ValueError("rate must be str/Decimal, never float")
        return Decimal(str(v))

    @field_validator("call", "wrap")
    @classmethod
    def _aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None or v.utcoffset() is None:
            raise ValueError("call/wrap must be timezone-aware")
        return v

    @field_validator("shoot_tz")
    @classmethod
    def _tz_resolves(cls, v: str) -> str:
        ZoneInfo(v)  # raises on an unknown IANA id
        return v


def _q(amount: Decimal) -> Decimal:
    return amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)


def _worked_hours(rec: OvertimeRecord) -> Decimal:
    utc = ZoneInfo("UTC")
    seconds = (rec.wrap.astimezone(utc) - rec.call.astimezone(utc)).total_seconds()
    return Decimal(str(seconds)) / Decimal("3600")


def compute_golden_hours(rec: OvertimeRecord) -> dict:
    worked = _worked_hours(rec)
    bands: list[dict] = []
    gross = Decimal("0.00")
    golden_hours = Decimal("0")

    for i, (lower, mult) in enumerate(TIER_BOUNDARIES):
        upper = TIER_BOUNDARIES[i + 1][0] if i + 1 < len(TIER_BOUNDARIES) else None
        if worked <= lower:
            break
        band_hours = worked - lower if upper is None else min(worked, upper) - lower
        if band_hours <= 0:
            continue
        band_pay = _q(band_hours * rec.hourly_rate * mult)
        gross += band_pay
        if lower >= GOLDEN_THRESHOLD:
            golden_hours += band_hours
        bands.append({
            "from_hour": str(lower),
            "to_hour": str(upper) if upper is not None else str(worked.quantize(Decimal("0.0001"))),
            "hours": str(band_hours.quantize(Decimal("0.0001"))),
            "multiplier": str(mult),
            "band_pay": str(band_pay),
        })

    zone = ZoneInfo(rec.shoot_tz)
    utc = ZoneInfo("UTC")
    payload = {
        "crew_id": rec.crew_id,
        "call_local": rec.call.astimezone(zone).isoformat(),
        "wrap_local": rec.wrap.astimezone(zone).isoformat(),
        "call_utc": rec.call.astimezone(utc).isoformat(),
        "wrap_utc": rec.wrap.astimezone(utc).isoformat(),
        "worked_hours": str(worked.quantize(Decimal("0.0001"))),
        "golden_hours": str(golden_hours.quantize(Decimal("0.0001"))),
        "bands": bands,
        "gross_pay": str(_q(gross)),
    }
    canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
    payload["payload_sha256"] = hashlib.sha256(canonical).hexdigest()
    return payload

Because compute_golden_hours reads its boundaries and multipliers from rule parameters and never touches the network, identical inputs always produce an identical payload and an identical hash. That determinism is what lets a guarantor’s auditor recompute a disputed golden-hour figure from the archived record alone. Walking the tiers as cumulative boundaries — each band spanning from its lower bound to the next, and the final open-ended band absorbing everything past the golden threshold — is what keeps a sixteen-hour day from being priced as though the whole span were golden, or as though none of it were.

The diagram traces one long day across the four bands and shows where the golden threshold begins.

Overtime tier bands across one long IATSE shoot day A worked span of sixteen hours is split into four cumulative bands along a single axis: straight time from hour zero to eight, time-and-a-half from eight to twelve, double time from twelve to fifteen, and golden time from fifteen to sixteen. Each band is labeled with its multiplier. A dashed marker at hour fifteen highlights the golden-hour threshold, and a note reads that only the span past that threshold is priced at the golden rate. One 16-hour shoot day, split into cumulative overtime bands boundaries and multipliers are agreement-versioned inputs Straight ×1.0 · 0–8h Time-and-a-half ×1.5 · 8–12h Double ×2.0 · 12–15h Golden ×2.5 · 15h+ golden threshold: hour 15 0h 4h 8h 12h 15h 16h Only the span past the golden threshold is priced at the golden rate; each earlier band is billed at its own multiplier and summed in Decimal.

Audit Trail Requirements

Every evaluation must be serialized to a write-once store so an examiner sees the full band breakdown, not just the gross. At minimum the persisted payload must carry: the crew_id; both shoot-local and normalized-UTC forms of the call and wrap; the computed worked_hours and the golden_hours; the per-band from_hour, to_hour, hours, multiplier, and band_pay as strings; the gross_pay; and the payload_sha256 fingerprint of the canonical payload. Persist the record to append-only, write-once storage — object storage with an immutability lock, or an event-sourced table with no in-place update — before any downstream ledger transaction commits, so a crash mid-run leaves a replayable record of intent rather than a silent gap. A later correction is a new compensating record, never an edit of the posted one. This is the same audit discipline the sibling engines apply, including DGA Overtime & Turnaround Rules and Pension & Health Fund Calculations, which is why the worked-hours figure this function produces is the same base those engines consume rather than a re-derived one.

Gotchas and Production Edge Cases

Cumulative, not flat, boundaries. The costly error is applying the golden multiplier to the whole day once the threshold is crossed. Golden time applies only to the hours past the golden threshold; the hours before it are still billed at straight, time-and-a-half, and double in sequence. Modeling the tiers as cumulative bounds and slicing each band from its lower to its upper edge is what makes the sixteenth hour expensive without repricing the first fifteen.

Daylight Saving Time boundaries. A night shoot whose span brackets a spring-forward or fall-back transition is where naive arithmetic fails: subtracting wall-clock times across the change miscounts worked hours by a full hour, which can push a day across — or short of — the golden threshold and mis-price the most expensive band. Anchoring both instants to an IANA zone through zoneinfo and differencing the normalized UTC values makes the transition invisible to the math while the audit record still shows the correct local times. The same per-instant zone discipline drives the rest-window check in Validating DGA 10-Hour Turnaround Rules in Python.

Multi-location and split days. A unit that travels mid-shoot, or a crew member whose day splits across two calls, must have each span stamped in its own production zone and, where the agreement requires, tiered per span rather than as one merged block. Store the shoot zone on the record and never infer it from the server clock, or the tier boundaries silently shift with the host machine.

Idempotency on replay. Because the payload hash is deterministic and the store is append-only, re-running a batch after a partial failure is safe only if the consumer deduplicates on (crew_id, call_utc, wrap_utc, payload_sha256); without that guard a retried chunk double-posts the golden-hour pay. When a wrap time is missing or a rate table is unavailable rather than final, route the record through the controlled path in Compliance Fallback Chains — a conservative provisional and a quarantine entry — rather than guessing a wrap inline.

Float contamination. Never build a Decimal from a float literal — Decimal(0.1) imports the binary rounding error. Read the rate, the multipliers, and the elapsed seconds through their string form, so a fractional cent compounded across a long-day crew never becomes the variance a guarantor asks you to explain.

Up one level: IATSE Crew Rule Validation, part of Guild Compliance & Rule Validation Automation.