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.
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.
Related Guides
- IATSE Crew Rule Validation: Meal Penalties, Turnaround, and Golden Hours in Python — the parent engine that runs this golden-hour tiering alongside meal-penalty and turnaround evaluation over one shoot-day timeline.
- Validating IATSE Meal Penalties in Python — the sibling walkthrough that reads the same timeline for stacked meal-penalty intervals.
- Validating DGA 10-Hour Turnaround Rules in Python — a cousin walkthrough applying the same timezone-aware, Decimal-first, hash-audited discipline to rest windows.
- Pension & Health Fund Calculations — where the worked-hours base and golden-hour premium become a fringe remittance.
- Guild Compliance & Rule Validation Automation — the reference architecture these crew checks sit inside.
Up one level: IATSE Crew Rule Validation, part of Guild Compliance & Rule Validation Automation.