Validating DGA 10-Hour Turnaround Rules in Python
The Directors Guild of America (DGA) Basic Agreement guarantees a director, unit production manager, or assistant director a minimum rest window — commonly ten hours — between wrap and the next scheduled call, and a shortfall in that window converts silently into penalty compensation on the daily cost report. The exact edge case this page solves is narrow and unforgiving: given a single wrap instant and the next call instant, each arriving in a shoot-local timezone and possibly straddling a Daylight Saving Time boundary, compute the turnaround shortfall to the minute, decide whether it breaches the required window, and emit an audit record whose penalty figure a completion guarantor can reproduce to the cent months later. Naive datetime subtraction and floating-point penalty math both fracture under real shooting conditions, so this walkthrough builds the check as a pure function over timezone-aware instants with Decimal money throughout.
Prerequisites and Context
This page extends the parent engine documented in DGA Overtime & Turnaround Rules; it assumes the wrap and call timestamps have already been cost-coded and typed upstream, and focuses entirely on the turnaround arithmetic and the audit trail around a single record. 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 (never 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 validated hour keys to exactly one budget line through Cost Code Standardization so any penalty accrual maps cleanly onto the ledger. The contract clause that matters is the turnaround guarantee of the applicable DGA agreement: because the precise threshold and rates vary by agreement, tier, and amendment, treat the ten-hour window and the premium as configurable rule parameters rather than hardcoded law.
Step-by-Step Implementation
Validation begins at the boundary. A Pydantic v2 model rejects any timestamp that is not timezone-aware, because a naive datetime is the single largest source of silent turnaround miscalculation. The shortfall is then a pure function of two aware instants — offset-invariant, so it can be computed on the normalized UTC values while display stays in the shoot-local zone — and the penalty is derived in Decimal from an agreement-governed rate table.
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from decimal import Decimal, ROUND_HALF_UP
from zoneinfo import ZoneInfo
from pydantic import BaseModel, ConfigDict, field_validator
# Rule parameters — resolve from the applicable DGA agreement, not hardcoded law.
REQUIRED_TURNAROUND_HOURS = Decimal("10")
# Forced-call premium as a multiplier on the day rate, keyed to shoot-day class.
FORCED_CALL_PREMIUM = {"studio": Decimal("1.5"), "location": Decimal("2.0")}
class TurnaroundRecord(BaseModel):
"""A validated wrap / next-call pair for one guild member on one shoot day."""
model_config = ConfigDict(frozen=True)
crew_id: str
shoot_class: str # "studio" or "location"
day_rate: Decimal # contractual day rate for the member
shoot_tz: str # IANA identifier, e.g. "America/Los_Angeles"
wrap: datetime # timezone-aware wrap instant
next_call: datetime # timezone-aware next scheduled call
@field_validator("wrap", "next_call")
@classmethod
def _must_be_aware(cls, v: datetime) -> datetime:
if v.tzinfo is None or v.utcoffset() is None:
raise ValueError("timestamp must be timezone-aware")
return v
@field_validator("shoot_tz")
@classmethod
def _tz_resolves(cls, v: str) -> str:
ZoneInfo(v) # raises if the IANA id is unknown
return v
def _quantize(amount: Decimal) -> Decimal:
return amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def validate_turnaround(record: TurnaroundRecord) -> dict:
zone = ZoneInfo(record.shoot_tz)
wrap_utc = record.wrap.astimezone(timezone.utc)
call_utc = record.next_call.astimezone(timezone.utc)
# Delta is offset-invariant; compute on aware instants, display in shoot zone.
elapsed = Decimal(str((call_utc - wrap_utc).total_seconds())) / Decimal("3600")
shortfall = REQUIRED_TURNAROUND_HOURS - elapsed
violation = shortfall > 0
if violation:
multiplier = FORCED_CALL_PREMIUM.get(record.shoot_class, Decimal("1.5"))
penalty = _quantize(record.day_rate * (multiplier - Decimal("1")))
else:
multiplier = Decimal("1")
penalty = Decimal("0.00")
payload = {
"crew_id": record.crew_id,
"shoot_class": record.shoot_class,
"wrap_local": record.wrap.astimezone(zone).isoformat(),
"call_local": record.next_call.astimezone(zone).isoformat(),
"wrap_utc": wrap_utc.isoformat(),
"call_utc": call_utc.isoformat(),
"elapsed_hours": str(elapsed.quantize(Decimal("0.0001"))),
"required_hours": str(REQUIRED_TURNAROUND_HOURS),
"shortfall_hours": str(shortfall.quantize(Decimal("0.0001"))),
"violation": violation,
"premium_multiplier": str(multiplier),
"penalty_amount": str(penalty),
}
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
payload["payload_sha256"] = hashlib.sha256(canonical).hexdigest()
return payload
Because validate_turnaround reads its threshold and premium 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 penalty from the archived record alone.
The validation flow normalizes the wrap and next-call timestamps, computes the rest delta, and compares it against the agreement-governed turnaround threshold before resolving any premium.
Audit Trail Requirements
Every evaluation — including the no-violation branch — must be serialized to a write-once store so an examiner sees an unbroken record rather than only the exceptions. At minimum the persisted payload must carry: the crew_id and shoot_class; both the shoot-local and normalized-UTC forms of the wrap and call instants; the computed elapsed_hours, the governing required_hours, and the resulting shortfall_hours; the violation flag; the premium_multiplier and derived penalty_amount as strings; 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, so the figure a report was built on stays recoverable. This is the same deterministic audit discipline required across sibling jurisdictions, including SAG-AFTRA Residuals Logic and Pension & Health Fund Calculations, which is why the normalization and hashing belong in one shared module rather than duplicated per union.
Gotchas and Production Edge Cases
Daylight Saving Time boundaries. A wrap on the night the clocks change is exactly where naive arithmetic fails: subtracting wall-clock times across a spring-forward or fall-back transition mis-measures the rest window by a full hour. Anchoring both instants to an IANA zone through zoneinfo and computing the delta on the normalized UTC values makes the transition invisible to the math while the audit record still shows the correct local times.
Multi-location shoots. A unit that wraps in America/New_York and calls the next morning in America/Los_Angeles must stamp each instant in its own production zone; a single hard-coded zone silently shifts the shortfall by three hours. Store the shoot zone on the record, as the model above does, and never infer it from the server clock. The same per-unit zone discipline is spelled out for cross-jurisdiction cost data in Async Batch Processing for Multi-Currency Shoots.
Overlapping penalty triggers. A shortened turnaround often coincides with a meal-penalty window, and the two must not double-count the same minutes. Resolve turnaround as its own line item keyed to the rest delta, keep the meal penalty on its own trigger, and let the ledger sum distinct codes rather than compounding one premium on top of another.
Float contamination. Never build a Decimal from a float literal — Decimal(0.1) imports the binary rounding error. Read rates and elapsed seconds through their string form, as Decimal(str(...)) above, so a single fractional cent compounded across a week of timecards never becomes the variance a guarantor asks you to explain.
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, wrap_utc, payload_sha256); without that guard a retried chunk double-posts the penalty. When an upstream rate table is missing or a guild API is slow, route the record through the controlled path defined in Compliance Fallback Chains — a conservative cached scale and a quarantine entry — rather than guessing a multiplier inline.
Related Guides
- DGA Overtime & Turnaround Rules — the parent engine that classifies turnaround shortfalls into tiered penalties and owns the rate-table structure this page consumes.
- Compliance Fallback Chains — the controlled path a record takes when a guild rate table is missing or an API is degraded.
- SAG-AFTRA Residuals Logic — a sibling engine that reuses the same timezone-aware, Decimal-first, hash-audited validation discipline for a different union.
- Pension & Health Fund Calculations — fringe math that runs against the same deterministic, reproducible base figures.
- Async Batch Processing for Multi-Currency Shoots — a cousin walkthrough applying identical per-unit IANA-zone and audit-first discipline to foreign-exchange cost data.
Up one level: DGA Overtime & Turnaround Rules, part of Guild Compliance & Rule Validation Automation.