diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..027ca27
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,56 @@
+name: CI
+
+on:
+ push:
+ branches: [main, "feature/**"]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ci-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ quality:
+ name: Python ${{ matrix.python-version }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.11", "3.12"]
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache: pip
+
+ - name: Install project and quality tools
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -e ".[demo,dev]"
+
+ - name: Lint
+ run: ruff check .
+
+ - name: Check formatting
+ run: ruff format --check .
+
+ - name: Run test suite
+ run: pytest -q
+
+ - name: Compile Python sources
+ run: python -m compileall -q src app.py scripts tests
+
+ - name: Smoke-test installed CLI
+ run: |
+ heatops-evaluate --mode heat_first --format json > /tmp/heatops-result.json
+ python -c 'import json; p=json.load(open("/tmp/heatops-result.json")); assert p["comparison"]["optimized_result"]["status"] in {"OPTIMAL", "FEASIBLE"}'
diff --git a/.streamlit/config.toml b/.streamlit/config.toml
new file mode 100644
index 0000000..2f12e14
--- /dev/null
+++ b/.streamlit/config.toml
@@ -0,0 +1,9 @@
+[theme]
+primaryColor = "#EA580C"
+backgroundColor = "#F8FAFC"
+secondaryBackgroundColor = "#FFF7ED"
+textColor = "#172033"
+font = "sans serif"
+
+[server]
+headless = true
diff --git a/.streamlit/secrets.toml.example b/.streamlit/secrets.toml.example
new file mode 100644
index 0000000..62a9dbc
--- /dev/null
+++ b/.streamlit/secrets.toml.example
@@ -0,0 +1,4 @@
+# Copy this file to .streamlit/secrets.toml for a deployed live-data demo.
+# The real secrets.toml file is ignored by git.
+FORTYGUARD_API_KEY = "replace-with-your-api-key"
+FORTYGUARD_BASE_URL = "https://api.fortyguard.com"
diff --git a/README.md b/README.md
index e69de29..623af36 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,179 @@
+
+# HeatOps
+
+[](https://github.com/MichelleHan7/HeatOps/actions/workflows/ci.yml)
+[](https://www.python.org/)
+
+**Heat-aware field operations planning powered by FortyGuard hyperlocal
+temperature intelligence.**
+
+HeatOps turns location- and time-specific temperature data into an actionable
+field schedule. It compares an operations-first baseline with a heat-aware
+schedule, makes the heat-versus-delay trade-off explicit, and explains every
+job that moved.
+
+The current demo models one Phoenix utility crew and five field jobs. On the
+bundled, traceable FortyGuard snapshot, the **Heat-first** preset reduces the
+operational Heat Load Score from **40.38 to 39.18 (2.97%)** by moving **3 of 5
+jobs**. Heat Load is a planning metric, not a medical risk assessment.
+
+## Demo highlights
+
+- A polished Streamlit dashboard with preset and custom optimization controls.
+- Side-by-side baseline and optimized timelines.
+- Hyperlocal temperature curves and a field-location map.
+- A visible Heat Load, delay, idle-time, and threshold-exposure comparison.
+- Plain-language, job-level explanations for every scheduling change.
+- Optional live FortyGuard refresh with validated cache and snapshot fallback.
+- A deterministic OR-Tools CP-SAT optimizer and a fair, temperature-unaware
+ baseline using the same feasibility constraints.
+
+## How it works
+
+```mermaid
+flowchart TD
+ FG["FortyGuard Heatmap API"] --> TS["Validated temperature service"]
+ SNAP["Traceable demo snapshot"] --> TS
+ TS --> MATRIX["Job × time temperature matrix"]
+ INPUTS["Jobs, crew, constraints"] --> OPT["CP-SAT scheduler"]
+ MATRIX --> OPT
+ OPT --> EVAL["Baseline comparison and explanations"]
+ EVAL --> UI["Streamlit dashboard and CLI"]
+```
+
+The optimizer generates feasible 15-minute start-time candidates for every
+job, enforces time windows, shift boundaries, worker skills, and non-overlap,
+then minimizes a configurable combination of normalized Heat Load and
+priority-weighted delay. See [Architecture](docs/architecture.md) for the
+formula, constraints, baseline design, and component boundaries.
+
+## Quick start
+
+Requirements: Python 3.11 or 3.12.
+
+```bash
+git clone https://github.com/MichelleHan7/HeatOps.git
+cd HeatOps
+python -m venv .venv
+source .venv/bin/activate
+python -m pip install --upgrade pip
+python -m pip install -e ".[demo,dev]"
+streamlit run app.py
+```
+
+The dashboard opens with the bundled Phoenix snapshot, so no API key is needed
+for the default demo.
+
+### CLI evaluation
+
+```bash
+heatops-evaluate --mode heat_first
+heatops-evaluate --mode balanced --format json
+heatops-evaluate --heat-priority 75
+```
+
+Valid presets are `operations_first`, `balanced`, and `heat_first`. The custom
+control assigns the selected percentage to Heat Load and the remainder to
+priority-weighted delay.
+
+## Live FortyGuard data
+
+Copy the appropriate example and add your own key locally:
+
+```bash
+cp .env.example .env
+# or, for Streamlit deployment:
+cp .streamlit/secrets.toml.example .streamlit/secrets.toml
+```
+
+Then either enable **Refresh from FortyGuard API** in the dashboard or run:
+
+```bash
+python scripts/fetch_temperature_matrix.py \
+ --jobs data/scenarios/phoenix-demo/jobs.json \
+ --date 2026-08-24 \
+ --output data/temperature_matrix.json
+```
+
+The API path is asynchronous. HeatOps creates one heatmap per requested time
+slot, polls to completion with bounded retries, spatially matches each job to a
+returned tile, validates the complete matrix, and caches valid results. If a
+live refresh fails, the dashboard retains the validated repository snapshot.
+The full contract is documented in [FortyGuard API integration](docs/api-integration.md).
+
+Never commit `.env` or `.streamlit/secrets.toml`; both are ignored.
+
+## Demo modes
+
+| Mode | Heat weight | Delay weight | Intended question |
+| --- | ---: | ---: | --- |
+| Operations-first | 0% | 100% | What is the earliest feasible schedule? |
+| Balanced | 50% | 50% | Where is a practical compromise? |
+| Heat-first | 100% | 0% | How far can the modeled Heat Load be reduced? |
+| Custom | 0–100% | Remaining weight | What changes under a chosen policy? |
+
+The Heat priority slider is intentionally enabled only for **Custom** mode;
+presets remain fixed and reproducible.
+
+## Reproducible evidence
+
+The repository separates facts from scenario assumptions:
+
+- Temperatures come from the existing FortyGuard ingestion snapshot. The file
+ checksum, date, AOI, resolution, and provenance live in
+ `data/scenarios/phoenix-demo/metadata.json`.
+- Job locations, time windows, priorities, skills, and physical-intensity
+ multipliers are explicit hackathon demo assumptions.
+- The baseline and HeatOps schedule share the same solver and constraints. The
+ only difference is whether temperature contributes to the objective.
+- Submission metrics are machine-checked against the scenario in
+ `tests/test_submission_artifacts.py`.
+
+## Project structure
+
+```text
+app.py Streamlit demo
+src/heatops/domain/ Data models, loaders, and shared configuration
+src/heatops/integrations/ FortyGuard client, AOI, matching, and cache service
+src/heatops/optimization/ Heat Load model, presets, baseline, and CP-SAT solver
+src/heatops/evaluation/ Comparable metrics and job-level explanations
+src/heatops/presentation.py UI-neutral chart and card records
+data/scenarios/phoenix-demo/ Reproducible jobs, crew, metadata, and temperatures
+scripts/ Data-fetch and evaluation entry points
+tests/ Unit, integration, scenario, UI, and artifact tests
+docs/ Architecture, demo, API, and submission guidance
+submission/ Machine-readable submission manifest
+```
+
+## Quality gates
+
+Run the same checks used by GitHub Actions:
+
+```bash
+ruff check .
+ruff format --check .
+pytest -q
+python -m compileall -q src app.py scripts tests
+heatops-evaluate --mode heat_first --format json
+```
+
+The tests do not call the live FortyGuard API. Network behavior is exercised
+through controlled fakes, while the end-to-end scenario and Streamlit smoke
+test use repository fixtures.
+
+## Submission package
+
+- [Demo guide](docs/demo-guide.md)
+- [Architecture](docs/architecture.md)
+- [FortyGuard API integration](docs/api-integration.md)
+- [Copy-ready project submission](docs/submission.md)
+- [2–5 minute video script](docs/video-script.md)
+- [Machine-readable manifest](submission/heatops-submission.json)
+
+## Current scope
+
+HeatOps is a decision-support prototype for a single crew and deterministic
+daily planning. It does not claim medical safety guidance, predict heat illness,
+or replace employer heat-safety procedures. Multi-crew routing, travel-time
+optimization, authentication, and production persistence are intentionally
+outside this hackathon submission.
diff --git a/app.py b/app.py
new file mode 100644
index 0000000..0df0ba1
--- /dev/null
+++ b/app.py
@@ -0,0 +1,647 @@
+import json
+import os
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Literal
+
+import altair as alt
+import pandas as pd
+import pydeck as pdk
+import streamlit as st
+from streamlit.errors import StreamlitSecretNotFoundError
+
+from heatops.cli import DEFAULT_SCENARIO
+from heatops.domain.config import SchedulerConfig
+from heatops.domain.loaders import load_jobs, load_temperature_matrix, load_workers
+from heatops.domain.models import (
+ Job,
+ OptimizationWeights,
+ TemperatureMatrix,
+ Worker,
+)
+from heatops.evaluation.models import ScheduleComparison
+from heatops.evaluation.service import compare_schedules
+from heatops.integrations.fortyguard import FortyGuardClient
+from heatops.integrations.temperature_service import (
+ TemperatureServiceError,
+ fetch_temperature_data,
+)
+from heatops.optimization.presets import PRESETS, weights_from_heat_priority
+from heatops.presentation import (
+ MODE_LABELS,
+ JobChangeRecord,
+ MapMarkerRecord,
+ MetricCardRecord,
+ TemperatureCurveRecord,
+ TimelineRecord,
+ TradeoffRecord,
+ build_job_change_records,
+ build_map_marker_records,
+ build_metric_card_records,
+ build_temperature_curve_records,
+ build_timeline_records,
+ build_tradeoff_records,
+)
+
+MODE_OPTIONS = (*MODE_LABELS.values(), "Custom")
+MODE_KEYS_BY_LABEL = {label: key for key, label in MODE_LABELS.items()}
+
+
+class ScenarioLoadError(RuntimeError):
+ """Raised when the bundled demo scenario cannot be loaded safely."""
+
+
+@dataclass(frozen=True)
+class DemoScenario:
+ jobs: tuple[Job, ...]
+ workers: tuple[Worker, ...]
+ selected_worker: Worker
+ temperature_matrix: TemperatureMatrix
+ metadata: dict
+
+
+@dataclass(frozen=True)
+class TemperatureSourceState:
+ matrix: TemperatureMatrix
+ source: Literal["snapshot", "live", "cache"]
+ label: str
+ detail: str
+ data_date: str
+
+
+@dataclass(frozen=True)
+class DashboardState:
+ mode_key: str
+ mode_label: str
+ weights: OptimizationWeights
+ comparison: ScheduleComparison
+ preset_comparisons: dict[str, ScheduleComparison]
+ tradeoff_records: tuple[TradeoffRecord, ...]
+
+
+def load_demo_scenario(path: str | Path = DEFAULT_SCENARIO) -> DemoScenario:
+ """Load and validate the deterministic Phoenix dashboard scenario."""
+
+ scenario_path = Path(path)
+
+ try:
+ jobs = tuple(load_jobs(scenario_path / "jobs.json"))
+ workers = tuple(load_workers(scenario_path / "workers.json"))
+ matrix = load_temperature_matrix(scenario_path / "temperature_matrix.json")
+ metadata = json.loads(
+ (scenario_path / "metadata.json").read_text(encoding="utf-8")
+ )
+ selected_worker_id = metadata["operational_assumptions"]["selected_worker_id"]
+ selected_worker = next(
+ worker for worker in workers if worker.id == selected_worker_id
+ )
+ except (KeyError, OSError, StopIteration, TypeError, ValueError) as error:
+ raise ScenarioLoadError(
+ f"Could not load the Phoenix demo scenario from {scenario_path}."
+ ) from error
+
+ if not jobs:
+ raise ScenarioLoadError("The Phoenix demo scenario contains no jobs.")
+
+ return DemoScenario(
+ jobs=jobs,
+ workers=workers,
+ selected_worker=selected_worker,
+ temperature_matrix=matrix,
+ metadata=metadata,
+ )
+
+
+def resolve_weights(
+ mode_label: str,
+ heat_priority: float,
+) -> tuple[str, OptimizationWeights]:
+ """Resolve a preset label or the dashboard's custom priority control."""
+
+ if mode_label == "Custom":
+ return "custom", weights_from_heat_priority(heat_priority)
+
+ try:
+ mode_key = MODE_KEYS_BY_LABEL[mode_label]
+ except KeyError as error:
+ valid = ", ".join(MODE_OPTIONS)
+ raise ValueError(
+ f"Unknown dashboard mode {mode_label!r}. Choose from: {valid}."
+ ) from error
+
+ return mode_key, PRESETS[mode_key]
+
+
+def _configured_fortyguard_client() -> FortyGuardClient | None:
+ api_key = os.getenv("FORTYGUARD_API_KEY")
+ base_url = os.getenv("FORTYGUARD_BASE_URL")
+
+ if not api_key:
+ try:
+ api_key = st.secrets.get("FORTYGUARD_API_KEY")
+ base_url = st.secrets.get("FORTYGUARD_BASE_URL", base_url)
+ except StreamlitSecretNotFoundError:
+ return None
+
+ if not api_key:
+ return None
+
+ return FortyGuardClient(api_key=api_key, base_url=base_url)
+
+
+def resolve_temperature_source(
+ scenario: DemoScenario,
+ use_live_data: bool,
+ *,
+ client: FortyGuardClient | None = None,
+ cache_dir: str | Path | None = None,
+) -> TemperatureSourceState:
+ """Use live/cached FortyGuard data when requested, otherwise the snapshot."""
+
+ temperature_metadata = scenario.metadata["temperature_data"]
+ snapshot_date = str(temperature_metadata["data_date"])
+
+ if not use_live_data:
+ return TemperatureSourceState(
+ matrix=scenario.temperature_matrix,
+ source="snapshot",
+ label="Bundled FortyGuard snapshot",
+ detail="Reproducible Phoenix data included with the demo.",
+ data_date=snapshot_date,
+ )
+
+ fetch_kwargs = {}
+
+ if cache_dir is not None:
+ fetch_kwargs["cache_dir"] = cache_dir
+
+ try:
+ result = fetch_temperature_data(
+ list(scenario.jobs),
+ snapshot_date,
+ tuple(temperature_metadata["time_slots"]),
+ client=client,
+ granularity=int(temperature_metadata["granularity_meters"]),
+ **fetch_kwargs,
+ )
+ except TemperatureServiceError:
+ return TemperatureSourceState(
+ matrix=scenario.temperature_matrix,
+ source="snapshot",
+ label="Bundled FortyGuard snapshot",
+ detail=(
+ "Live data was unavailable, so HeatOps kept the validated demo "
+ "snapshot."
+ ),
+ data_date=snapshot_date,
+ )
+
+ source = result.metadata.source
+ label = (
+ "Live FortyGuard data" if source == "live" else "Cached FortyGuard API response"
+ )
+ return TemperatureSourceState(
+ matrix=result.matrix,
+ source=source,
+ label=label,
+ detail=(
+ "Temperature matrix passed job/time-slot validation before optimization."
+ ),
+ data_date=result.metadata.data_date,
+ )
+
+
+def build_dashboard_state(
+ scenario: DemoScenario,
+ temperature_matrix: TemperatureMatrix,
+ mode_label: str,
+ heat_priority: float,
+) -> DashboardState:
+ """Compute the selected result and the three preset tradeoff points."""
+
+ mode_key, weights = resolve_weights(mode_label, heat_priority)
+ jobs = list(scenario.jobs)
+ worker = scenario.selected_worker
+ config = SchedulerConfig()
+ preset_comparisons = {
+ preset: compare_schedules(
+ jobs,
+ temperature_matrix,
+ worker=worker,
+ weights=preset_weights,
+ config=config,
+ )
+ for preset, preset_weights in PRESETS.items()
+ }
+
+ if mode_key in preset_comparisons:
+ comparison = preset_comparisons[mode_key]
+ tradeoff_comparisons = preset_comparisons
+ tradeoff_weights = PRESETS
+ else:
+ comparison = compare_schedules(
+ jobs,
+ temperature_matrix,
+ worker=worker,
+ weights=weights,
+ config=config,
+ )
+ tradeoff_comparisons = {**preset_comparisons, mode_key: comparison}
+ tradeoff_weights = {**PRESETS, mode_key: weights}
+
+ return DashboardState(
+ mode_key=mode_key,
+ mode_label=mode_label,
+ weights=weights,
+ comparison=comparison,
+ preset_comparisons=preset_comparisons,
+ tradeoff_records=build_tradeoff_records(
+ tradeoff_comparisons,
+ tradeoff_weights,
+ ),
+ )
+
+
+def _records_frame(records: tuple) -> pd.DataFrame:
+ return pd.DataFrame(asdict(record) for record in records)
+
+
+def _timeline_chart(records: tuple[TimelineRecord, ...]) -> alt.Chart:
+ frame = _records_frame(records)
+ base_date = pd.Timestamp("2026-08-24")
+ frame["start"] = base_date + pd.to_timedelta(frame["start_minute"], unit="m")
+ frame["end"] = base_date + pd.to_timedelta(frame["end_minute"], unit="m")
+
+ return (
+ alt.Chart(frame)
+ .mark_bar(cornerRadius=5, height=22)
+ .encode(
+ x=alt.X("start:T", title="Time", axis=alt.Axis(format="%H:%M")),
+ x2="end:T",
+ y=alt.Y("job_name:N", title=None, sort=None),
+ color=alt.Color(
+ "temperature_c:Q",
+ title="Temperature (C)",
+ scale=alt.Scale(scheme="orangered"),
+ ),
+ tooltip=[
+ alt.Tooltip("job_name:N", title="Job"),
+ alt.Tooltip("start_time:N", title="Start"),
+ alt.Tooltip("end_time:N", title="End"),
+ alt.Tooltip("temperature_c:Q", title="Temperature", format=".2f"),
+ alt.Tooltip("heat_load:Q", title="Heat Load", format=".2f"),
+ ],
+ )
+ .properties(height=max(220, len(records) * 42))
+ )
+
+
+def _temperature_chart(
+ records: tuple[TemperatureCurveRecord, ...],
+ threshold_c: float,
+) -> alt.LayerChart:
+ frame = _records_frame(records)
+ base_date = pd.Timestamp("2026-08-24")
+ frame["time"] = base_date + pd.to_timedelta(frame["minute"], unit="m")
+ curves = (
+ alt.Chart(frame)
+ .mark_line(point=True, strokeWidth=2.5)
+ .encode(
+ x=alt.X("time:T", title="Time", axis=alt.Axis(format="%H:%M")),
+ y=alt.Y(
+ "temperature_c:Q",
+ title="Modeled temperature (C)",
+ scale=alt.Scale(zero=False),
+ ),
+ color=alt.Color("job_name:N", title="Job"),
+ tooltip=[
+ alt.Tooltip("job_name:N", title="Job"),
+ alt.Tooltip("time_label:N", title="Time"),
+ alt.Tooltip("temperature_c:Q", title="Temperature", format=".2f"),
+ ],
+ )
+ )
+ threshold = (
+ alt.Chart(pd.DataFrame({"threshold": [threshold_c]}))
+ .mark_rule(color="#475569", strokeDash=[6, 4])
+ .encode(
+ y="threshold:Q",
+ tooltip=[alt.Tooltip("threshold:Q", title="Heat threshold")],
+ )
+ )
+ return curves + threshold
+
+
+def _tradeoff_chart(records: tuple[TradeoffRecord, ...]) -> alt.Chart:
+ frame = _records_frame(records)
+ return (
+ alt.Chart(frame)
+ .mark_circle(size=220, opacity=0.9)
+ .encode(
+ x=alt.X(
+ "priority_weighted_delay_hours:Q",
+ title="Priority-weighted delay (hours)",
+ ),
+ y=alt.Y(
+ "total_heat_load:Q",
+ title="Operational Heat Load",
+ scale=alt.Scale(zero=False),
+ ),
+ color=alt.Color("mode_label:N", title="Mode"),
+ tooltip=[
+ alt.Tooltip("mode_label:N", title="Mode"),
+ alt.Tooltip("heat_weight_percent:Q", title="Heat weight", format=".0f"),
+ alt.Tooltip("total_heat_load:Q", title="Heat Load", format=".2f"),
+ alt.Tooltip(
+ "priority_weighted_delay_hours:Q",
+ title="Weighted delay",
+ format=".2f",
+ ),
+ alt.Tooltip("moved_jobs:Q", title="Jobs moved"),
+ ],
+ )
+ .properties(height=320)
+ )
+
+
+def _schedule_map(
+ baseline: tuple[MapMarkerRecord, ...],
+ optimized: tuple[MapMarkerRecord, ...],
+) -> pdk.Deck:
+ baseline_frame = _records_frame(baseline)
+ optimized_frame = _records_frame(optimized)
+ layers = [
+ pdk.Layer(
+ "ScatterplotLayer",
+ baseline_frame,
+ get_position="[longitude, latitude]",
+ get_fill_color=[100, 116, 139, 115],
+ get_radius=145,
+ pickable=True,
+ ),
+ pdk.Layer(
+ "ScatterplotLayer",
+ optimized_frame,
+ get_position="[longitude, latitude]",
+ get_fill_color=[234, 88, 12, 215],
+ get_radius=85,
+ pickable=True,
+ ),
+ ]
+ all_markers = (*baseline, *optimized)
+ center_latitude = sum(marker.latitude for marker in all_markers) / len(all_markers)
+ center_longitude = sum(marker.longitude for marker in all_markers) / len(
+ all_markers
+ )
+
+ return pdk.Deck(
+ layers=layers,
+ initial_view_state=pdk.ViewState(
+ latitude=center_latitude,
+ longitude=center_longitude,
+ zoom=12.5,
+ pitch=25,
+ ),
+ map_style="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
+ tooltip={
+ "html": (
+ "{schedule}: {job_name}
Start: {start_time}
"
+ "Temperature: {temperature_c} C
Heat Load: {heat_load}"
+ )
+ },
+ )
+
+
+def _render_data_source(source: TemperatureSourceState) -> None:
+ message = f"{source.label} · data date {source.data_date}. {source.detail}"
+
+ if source.source == "live":
+ st.success(message, icon="✅")
+ elif "unavailable" in source.detail.lower():
+ st.warning(message, icon="⚠️")
+ else:
+ st.info(message, icon="ℹ️")
+
+
+def _render_metric_cards(cards: tuple[MetricCardRecord, ...]) -> None:
+ first_row = st.columns(3)
+ second_row = st.columns(3)
+
+ for column, card in zip((*first_row, *second_row), cards, strict=True):
+ column.metric(
+ card.label,
+ card.display_value,
+ card.display_delta,
+ delta_color="inverse",
+ help=card.help_text,
+ )
+
+
+def _render_schedule_tradeoff(comparison: ScheduleComparison) -> None:
+ delay_delta = (
+ comparison.optimized_metrics.priority_weighted_delay_hours
+ - comparison.baseline_metrics.priority_weighted_delay_hours
+ )
+ idle_delta = (
+ comparison.optimized_metrics.idle_minutes
+ - comparison.baseline_metrics.idle_minutes
+ )
+
+ if delay_delta > 0 or idle_delta > 0:
+ st.warning(
+ "Operational trade-off: the selected schedule adds "
+ f"{max(delay_delta, 0):.2f} priority-weighted delay hours and "
+ f"{max(idle_delta, 0):.0f} idle minutes versus the baseline."
+ )
+ else:
+ st.success(
+ "The selected schedule does not add priority-weighted delay or crew "
+ "idle time versus the baseline."
+ )
+
+
+def _render_job_changes(records: tuple[JobChangeRecord, ...]) -> None:
+ for record in records:
+ icon = "↔️" if record.moved else "•"
+
+ with st.container(border=True):
+ st.markdown(f"**{icon} {record.job_name}** · `{record.job_id}`")
+ st.caption(
+ f"{record.baseline_start} → {record.optimized_start} · "
+ f"Temperature {record.temperature_change} · "
+ f"Heat Load {record.heat_load_change}"
+ )
+ st.write(record.explanation)
+
+
+def main() -> None:
+ st.set_page_config(
+ page_title="HeatOps · Heat-aware field operations",
+ page_icon="☀️",
+ layout="wide",
+ )
+
+ try:
+ scenario = load_demo_scenario()
+ except ScenarioLoadError as error:
+ st.error(str(error), icon="🚨")
+ return
+
+ st.title("HeatOps")
+ st.markdown(
+ "**Heat-aware field operations planning powered by FortyGuard hyperlocal "
+ "temperature intelligence.**"
+ )
+ st.caption(
+ "Phoenix utility demo · Operational Heat Load is a planning score, not a "
+ "medical risk assessment."
+ )
+
+ with st.sidebar:
+ st.header("Optimization controls")
+ mode_label = st.selectbox(
+ "Optimization mode",
+ MODE_OPTIONS,
+ index=1,
+ help="Presets make the heat-versus-delay trade-off explicit.",
+ )
+ heat_priority = st.slider(
+ "Heat priority",
+ min_value=0,
+ max_value=100,
+ value=50,
+ step=5,
+ disabled=mode_label != "Custom",
+ help="Custom mode gives the remaining weight to operational delay.",
+ )
+ use_live_data = st.checkbox(
+ "Refresh from FortyGuard API",
+ value=False,
+ help="Falls back to the bundled validated snapshot if live data is unavailable.",
+ )
+ optimize_requested = st.button(
+ "Optimize schedule",
+ type="primary",
+ width="stretch",
+ )
+ st.divider()
+ st.caption(
+ f"Crew: {scenario.selected_worker.name} · "
+ f"{scenario.selected_worker.shift_start}–{scenario.selected_worker.shift_end}"
+ )
+ st.caption(f"Jobs: {len(scenario.jobs)} · Slot size: 15 minutes")
+
+ if optimize_requested or "dashboard_state" not in st.session_state:
+ try:
+ client = _configured_fortyguard_client() if use_live_data else None
+ source = resolve_temperature_source(
+ scenario,
+ use_live_data,
+ client=client,
+ )
+ dashboard = build_dashboard_state(
+ scenario,
+ source.matrix,
+ mode_label,
+ heat_priority,
+ )
+ except (RuntimeError, TypeError, ValueError) as error:
+ st.error(f"HeatOps could not build this schedule: {error}", icon="🚨")
+ return
+
+ st.session_state["temperature_source"] = source
+ st.session_state["dashboard_state"] = dashboard
+
+ source = st.session_state["temperature_source"]
+ dashboard = st.session_state["dashboard_state"]
+ comparison = dashboard.comparison
+ jobs = list(scenario.jobs)
+ moved_job_ids = {change.job_id for change in comparison.job_changes if change.moved}
+
+ _render_data_source(source)
+ st.subheader(f"{dashboard.mode_label} result")
+ heat_weight, delay_weight = dashboard.weights.normalized()
+ st.caption(
+ f"Objective weights: {heat_weight:.0%} heat · {delay_weight:.0%} delay · "
+ f"{comparison.moved_jobs} of {len(jobs)} jobs moved"
+ )
+ _render_metric_cards(build_metric_card_records(comparison))
+ _render_schedule_tradeoff(comparison)
+
+ st.subheader("Baseline vs optimized timeline")
+ baseline_records = build_timeline_records(
+ comparison.baseline_result,
+ jobs,
+ "Operations-first baseline",
+ )
+ optimized_records = build_timeline_records(
+ comparison.optimized_result,
+ jobs,
+ dashboard.mode_label,
+ )
+ baseline_column, optimized_column = st.columns(2)
+
+ with baseline_column:
+ st.markdown("#### Operations-first baseline")
+ st.altair_chart(
+ _timeline_chart(baseline_records),
+ width="stretch",
+ )
+
+ with optimized_column:
+ st.markdown(f"#### HeatOps · {dashboard.mode_label}")
+ st.altair_chart(
+ _timeline_chart(optimized_records),
+ width="stretch",
+ )
+
+ st.subheader("Field locations")
+ baseline_markers = build_map_marker_records(
+ comparison.baseline_result,
+ jobs,
+ "Baseline",
+ moved_job_ids,
+ )
+ optimized_markers = build_map_marker_records(
+ comparison.optimized_result,
+ jobs,
+ "HeatOps",
+ moved_job_ids,
+ )
+ st.caption(
+ "Large gray markers show baseline assignments; orange markers show HeatOps."
+ )
+ st.pydeck_chart(
+ _schedule_map(baseline_markers, optimized_markers),
+ width="stretch",
+ )
+
+ chart_column, tradeoff_column = st.columns(2)
+
+ with chart_column:
+ st.subheader("FortyGuard temperature curves")
+ temperature_records = build_temperature_curve_records(
+ jobs,
+ source.matrix,
+ )
+ st.altair_chart(
+ _temperature_chart(
+ temperature_records,
+ SchedulerConfig().heat_threshold_c,
+ ),
+ width="stretch",
+ )
+
+ with tradeoff_column:
+ st.subheader("Heat vs delay trade-off")
+ st.altair_chart(
+ _tradeoff_chart(dashboard.tradeoff_records),
+ width="stretch",
+ )
+
+ st.subheader("Why the schedule changed")
+ _render_job_changes(build_job_change_records(comparison, jobs))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/data/scenarios/phoenix-demo/metadata.json b/data/scenarios/phoenix-demo/metadata.json
index bb66214..d7136a7 100644
--- a/data/scenarios/phoenix-demo/metadata.json
+++ b/data/scenarios/phoenix-demo/metadata.json
@@ -33,7 +33,7 @@
"operational_assumptions": {
"description": "Hackathon demo jobs, time windows, priorities, skills, and physical-intensity multipliers model a single Phoenix utility crew's workday.",
"selected_worker_id": "CREW-001",
- "heat_threshold_c": 30.0,
+ "heat_threshold_c": 32.0,
"slot_minutes": 15
},
"aoi": {
diff --git a/data/temperature_matrix.json b/data/temperature_matrix.json
index 06c62e4..73ebebb 100644
--- a/data/temperature_matrix.json
+++ b/data/temperature_matrix.json
@@ -84,4 +84,4 @@
"19:00": 38.2803
}
}
-}
\ No newline at end of file
+}
diff --git a/docs/api-integration.md b/docs/api-integration.md
new file mode 100644
index 0000000..a83969d
--- /dev/null
+++ b/docs/api-integration.md
@@ -0,0 +1,140 @@
+# FortyGuard API integration
+
+This document records exactly how the HeatOps codebase uses FortyGuard. It is
+both implementation documentation and the API-usage evidence for the
+hackathon submission.
+
+## Configuration
+
+| Variable | Required | Default |
+| --- | --- | --- |
+| `FORTYGUARD_API_KEY` | Yes for live mode | None |
+| `FORTYGUARD_BASE_URL` | No | `https://api.fortyguard.com` |
+
+The HTTP client sends the key in the `api-key` header. Local development loads
+`.env`; Streamlit can also read `.streamlit/secrets.toml`. Neither secret file
+is committed.
+
+## Request lifecycle
+
+```mermaid
+sequenceDiagram
+ participant H as HeatOps
+ participant F as FortyGuard API
+ H->>F: POST /v1/heatmap
+ F-->>H: data.activity_id
+ loop Bounded polling
+ H->>F: GET /v1/status/{activity_id}
+ F-->>H: status and result
+ end
+ H->>H: Match tiles and validate matrix
+ H->>H: Cache complete result
+```
+
+For every requested time slot,
+`src/heatops/integrations/temperature_service.py` calls:
+
+1. `FortyGuardClient.create_heatmap(...)`.
+2. `FortyGuardClient.wait_for_result(...)`.
+3. `match_jobs_to_temperatures(...)` on the returned GeoJSON features.
+
+One AOI is reused across the slots. The resulting values are assembled into a
+`job_id × time_slot` temperature matrix for the optimizer.
+
+## Heatmap request
+
+`POST /v1/heatmap` receives this shape:
+
+```json
+{
+ "polygon_aoi": {"type": "FeatureCollection", "features": []},
+ "date_time": {
+ "start_date": "2026-08-24",
+ "start_time": "08:00",
+ "filter_type": 1
+ },
+ "granularity": 100
+}
+```
+
+The real `polygon_aoi` is a padded bounding polygon derived from all job
+coordinates. `granularity` is an integer in meters. HeatOps requires a
+non-empty `data.activity_id` in the response.
+
+## Status polling and result shape
+
+`GET /v1/status/{activity_id}` is polled until:
+
+- `completed` or `succeeded`: `data.result` must be an object;
+- `failed` or `error`: HeatOps raises an activity error with available detail;
+- the maximum number of polls is reached: HeatOps raises a timeout error.
+
+The temperature service expects completed output to contain
+`map_data.features`. Each feature must be an object. Spatial matching uses job
+latitude/longitude and feature geometry; a time slot is rejected if any job
+has no covering tile.
+
+## Validation contract
+
+A matrix enters optimization only when:
+
+- every requested job ID exists;
+- every requested time slot exists for every job;
+- every value is a finite number;
+- time labels are valid and unique.
+
+This completeness check matters because silent gaps could bias the schedule
+toward a job or time that simply lacked data.
+
+## Retries, cache, and fallback
+
+The client uses a 30-second request timeout by default. Network failures and
+HTTP `429`, `500`, `502`, `503`, and `504` responses are retried up to three
+attempts with exponential backoff. Activity polling is also bounded (default:
+120 checks, five seconds apart).
+
+Only a complete, validated live matrix is written to `data/cache/`. Cache files
+contain the matrix plus requested date, data date, time slots, granularity,
+generation time, activity IDs, and cache path. Invalid or incomplete cache
+files are skipped.
+
+The service fallback order is:
+
+1. live FortyGuard result;
+2. latest compatible validated cache;
+3. for the dashboard, the bundled repository snapshot.
+
+The UI always labels the active source. An API failure cannot silently present
+the snapshot as live data.
+
+## Reproducible Phoenix snapshot
+
+The default demo does not fabricate temperatures or require live API access.
+It uses the repository's existing FortyGuard ingestion output, copied without
+alteration to `data/scenarios/phoenix-demo/temperature_matrix.json`.
+
+Recorded provenance:
+
+| Field | Value |
+| --- | --- |
+| Provider | FortyGuard |
+| Data date | 2026-08-24 |
+| Time range | 08:00–19:00 hourly |
+| Granularity | 100 m |
+| SHA-256 | `e6080bde546f89b238b43a85826e51d11dc6f03bd3694835e4281dc0f134eab2` |
+
+`tests/test_phoenix_demo_scenario.py` verifies both the hash and the byte-for-byte
+scenario copy.
+
+## Test coverage
+
+- `tests/test_fortyguard_client.py`: request schema, headers, polling, retries,
+ failures, malformed responses, and timeouts.
+- `tests/test_temperature_matcher.py`: GeoJSON tile-to-job matching.
+- `tests/test_temperature_service.py`: end-to-end orchestration, validation,
+ cache writes, fallback, and unavailable-data behavior.
+- `tests/test_fetch_temperature_script.py`: command-line fetch behavior.
+- `tests/test_app_smoke.py`: dashboard source selection and safe snapshot fallback.
+
+CI uses fake clients and the repository snapshot; it never requires an API key
+or consumes FortyGuard quota.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..c9cf6d9
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,142 @@
+# HeatOps architecture
+
+HeatOps is organized as a small decision-support system: ingest hyperlocal
+temperature data, convert operational inputs into a constrained scheduling
+problem, compare the result with a fair baseline, then render evidence without
+putting business logic in the UI.
+
+## System context
+
+```mermaid
+flowchart TD
+ API["FortyGuard API"] --> INGEST["Temperature ingestion"]
+ CACHE["Validated cache or snapshot"] --> INGEST
+ INGEST --> CORE["Scheduling and evaluation core"]
+ OPS["Jobs, crew, and policy weights"] --> CORE
+ CORE --> PRESENT["Presentation adapters"]
+ PRESENT --> APP["Streamlit dashboard"]
+ CORE --> CLI["CLI / JSON output"]
+```
+
+The core remains presentation-neutral. Streamlit consumes immutable records
+from `src/heatops/presentation.py`; the CLI serializes the same evaluation
+models. This keeps demo work from changing optimizer behavior.
+
+## Component boundaries
+
+| Layer | Primary files | Responsibility |
+| --- | --- | --- |
+| Domain | `src/heatops/domain/` | Validated jobs, workers, weights, time helpers, and shared scheduler configuration |
+| Integration | `src/heatops/integrations/` | FortyGuard HTTP contract, AOI generation, tile matching, matrix validation, caching, and fallback |
+| Optimization | `src/heatops/optimization/` | Heat Load calculation, policy presets, fair baseline, and deterministic CP-SAT model |
+| Evaluation | `src/heatops/evaluation/` | Comparable schedule metrics, before/after records, and explanations |
+| Presentation | `src/heatops/presentation.py` | UI-neutral timelines, map markers, curves, metric cards, and trade-off points |
+| Interfaces | `app.py`, `src/heatops/cli.py` | Interactive dashboard and scriptable text/JSON output |
+
+Dependencies point inward: interfaces depend on presentation and evaluation;
+the optimizer does not import Streamlit, Pandas, Altair, or PyDeck.
+
+## End-to-end data flow
+
+1. Load jobs, crews, scenario metadata, and a temperature matrix.
+2. When live mode is requested, build a padded GeoJSON AOI from job locations.
+3. Request and poll one FortyGuard heatmap for each hourly time slot.
+4. Match each job coordinate to a heatmap feature, validate every job/time
+ value, and cache only a complete matrix.
+5. Build the operations-first baseline using zero heat weight.
+6. Build the selected HeatOps schedule with preset or custom weights.
+7. Calculate both schedules' Heat Load, delay, threshold minutes, idle time,
+ peak temperature, and job movements.
+8. Convert the comparison to presentation records for the dashboard.
+
+If live retrieval fails, the temperature service first checks a validated cache.
+The dashboard adds a final deterministic fallback to the bundled snapshot and
+shows the active source to the viewer.
+
+## Scheduling model
+
+For each job, HeatOps creates a candidate for every allowed start time on a
+15-minute grid. Each candidate has a Boolean selection variable and an optional
+fixed-size interval.
+
+### Hard constraints
+
+- Exactly one candidate is selected for every job.
+- A job starts no earlier than its time window or crew shift.
+- A job ends no later than its deadline or crew shift.
+- The selected crew has every required skill.
+- Selected job intervals do not overlap.
+
+All policy modes, including the baseline, use these same constraints.
+
+### Operational Heat Load Score
+
+For each 15-minute interval `i` of job `j`, the modeled contribution is:
+
+\[
+L_{j,i} = \max(T_{j,i} - T_{threshold}, 0)
+ \times \frac{minutes_i}{60}
+ \times intensity_j
+\]
+
+The job score is the sum of its interval contributions; the schedule score is
+the sum across jobs. Temperatures at quarter-hour starts are linearly
+interpolated from the hourly matrix. The runtime threshold is 32 °C.
+
+This is a transparent operational planning score. It is not WBGT, a medical
+risk model, a regulatory limit, or a prediction of injury.
+
+### Objective
+
+Each candidate's Heat Load and priority-weighted delay are independently
+normalized over the scenario's candidate ranges. HeatOps minimizes:
+
+\[
+weight_{heat} \times normalized\_heat
++ weight_{delay} \times normalized\_priority\_delay
+\]
+
+Integer scaling makes the objective compatible with CP-SAT. A small,
+deterministic start-index tie-break and fixed solver seed make results stable
+for a demo and test suite.
+
+### Fair baseline
+
+`build_baseline_schedule` invokes the same optimizer with heat weight `0` and
+delay weight `1`. Temperature is evaluated only after the baseline schedule is
+chosen. This controls for feasibility logic: the comparison changes the
+decision policy, not the scheduling rules.
+
+## Scenario and provenance
+
+The Phoenix demo has two distinct input classes:
+
+| Input | Status | Source |
+| --- | --- | --- |
+| Hourly hyperlocal temperatures | Traceable data snapshot | Existing FortyGuard ingestion output, copied byte-for-byte into the scenario |
+| Jobs, locations, windows, priorities, skills, intensity | Demo assumptions | Explicit JSON fixtures for the hackathon narrative |
+| Worker shift and skills | Demo assumptions | Explicit JSON fixture |
+| Threshold and slot size | Model configuration | `SchedulerConfig` and scenario metadata |
+
+`data/scenarios/phoenix-demo/metadata.json` records the snapshot SHA-256,
+source date, provider, granularity, AOI, time slots, and disclaimer. Tests verify
+the checksum and scenario copy.
+
+## Reliability decisions
+
+- HTTP requests have timeouts, bounded retries, and exponential backoff for
+ transient status codes.
+- Asynchronous activities have bounded polling and explicit failed/timeout
+ exceptions.
+- Responses are schema-checked before use.
+- A matrix is accepted only when every requested job/time value is finite.
+- Cache files include source metadata and are revalidated when loaded.
+- API keys come from environment or Streamlit secrets and are never serialized.
+- Tests use fake sessions/clients; CI never consumes a live API quota.
+
+## Extension seams
+
+The current single-crew model is deliberate hackathon scope. Later work can add
+multiple crew assignment, travel time, breaks, rolling replanning, or persistent
+scenario storage behind the existing domain and evaluation boundaries. Those
+features are not part of Steps 12–16 and are not implemented in this submission.
diff --git a/docs/demo-guide.md b/docs/demo-guide.md
new file mode 100644
index 0000000..df73761
--- /dev/null
+++ b/docs/demo-guide.md
@@ -0,0 +1,116 @@
+# HeatOps demo guide
+
+This runbook is designed for a reliable live judging demo using the repository
+snapshot. It also documents the optional live FortyGuard path and recovery
+steps.
+
+## Before presenting
+
+```bash
+python -m pip install -e ".[demo,dev]"
+pytest -q
+streamlit run app.py
+```
+
+Use a browser width of at least 1280 px, collapse unrelated tabs, and open the
+app before screen sharing. Keep **Refresh from FortyGuard API** off for the main
+path: the bundled snapshot is traceable, deterministic, and does not depend on
+network latency or quota.
+
+Expected opening state:
+
+- Phoenix Utility Field Operations scenario.
+- Crew Alpha and five jobs.
+- Balanced preset selected.
+- Bundled FortyGuard snapshot status visible above the metrics.
+- Heat priority slider disabled until Custom is selected.
+
+## Recommended 3-minute click path
+
+### 1. Frame the problem — 20 seconds
+
+Field dispatch usually optimizes operational timing without seeing how heat
+changes block by block and hour by hour. HeatOps adds that missing decision
+factor while keeping deadlines, skills, shifts, and non-overlap as hard
+constraints.
+
+Point to the visible data-source status. State that temperatures come from a
+traceable FortyGuard snapshot; jobs and policy weights are explicit demo
+assumptions.
+
+### 2. Establish the baseline — 30 seconds
+
+Select **Operations-first**, then click **Optimize schedule**. Point out:
+
+- it is the earliest feasible, temperature-unaware schedule;
+- the baseline and optimized timelines match in this mode;
+- the same constraints and solver are used in every mode.
+
+### 3. Show the impact — 60 seconds
+
+Select **Heat-first** and optimize. Lead with the top cards:
+
+- Heat Load changes from **40.38 to 39.18**;
+- reduction is **2.97%**;
+- **3 of 5 jobs** move.
+
+Then compare the side-by-side timelines. Use the explanations section to show
+that each change includes old/new time, temperature change, and Heat Load
+change. If delay or idle time rises, call out the warning rather than hiding the
+trade-off.
+
+### 4. Prove FortyGuard matters — 35 seconds
+
+Use the temperature curves and location map. Explain that the optimizer sees a
+job-by-time matrix, not one citywide forecast. Each field coordinate is matched
+to a FortyGuard heatmap tile before scheduling.
+
+### 5. Make the policy interactive — 25 seconds
+
+Select **Custom**. Move the Heat priority slider, for example from 25% to 75%,
+and optimize after each choice. Point to the trade-off chart: HeatOps is a
+decision-support surface, not a hidden one-size-fits-all policy.
+
+### 6. Close — 10 seconds
+
+“HeatOps turns hyperlocal heat intelligence into an explainable daily action:
+which job should move, when, and what operational trade-off that creates.”
+
+## Optional live API moment
+
+Only use this after the deterministic story is complete.
+
+1. Configure `FORTYGUARD_API_KEY` in `.streamlit/secrets.toml` or the
+ environment.
+2. Enable **Refresh from FortyGuard API**.
+3. Click **Optimize schedule**.
+4. Point to the data-source banner, which says whether the result is live,
+ cached, or the bundled snapshot.
+
+The request may take time because HeatOps creates and polls an asynchronous
+heatmap for every time slot. A network or API failure is non-fatal: the app
+shows the fallback and continues with validated data.
+
+## Failure recovery
+
+| Symptom | Recovery |
+| --- | --- |
+| App does not start | Confirm Python 3.11/3.12 and run `python -m pip install -e ".[demo,dev]"` |
+| Missing API key | Turn live refresh off, or copy `.streamlit/secrets.toml.example` and add the key |
+| Live refresh falls back | Continue the demo and explicitly point out the resilience behavior |
+| Scenario load error | Restore all files under `data/scenarios/phoenix-demo/` and run `pytest -q` |
+| Chart layout is cramped | Widen the browser and keep Streamlit in wide mode |
+| Unexpected evidence numbers | Run `pytest tests/test_submission_artifacts.py -q`; do not quote stale numbers |
+
+## Recording checklist
+
+- Record at 1080p if possible; verify labels are readable.
+- Do not expose `.env`, Streamlit secrets, browser developer tools, or API keys.
+- Keep the final video between 2 and 5 minutes.
+- Show the repository name and FortyGuard data-source banner.
+- Show at least one baseline/optimized timeline comparison.
+- State the 2.97% result as scenario evidence, not a universal guarantee.
+- Include one job-level explanation and the trade-off chart.
+- End with the repository URL and a single-sentence impact statement.
+
+The timed narration is in [Video script](video-script.md).
diff --git a/docs/submission.md b/docs/submission.md
new file mode 100644
index 0000000..b8f1c9e
--- /dev/null
+++ b/docs/submission.md
@@ -0,0 +1,125 @@
+# HeatOps — hackathon submission copy
+
+This page is a copy-ready submission package aligned to the deliverables listed
+on the [FortyGuard Hackathon 2026 page](https://www.fortyguard.com/hackathon26).
+Repository and runnable-demo information are complete. The hosted demo and
+video URLs must be added after those artifacts are published.
+
+## Submission links
+
+| Deliverable | Value |
+| --- | --- |
+| Project | HeatOps |
+| Code repository | https://github.com/MichelleHan7/HeatOps |
+| Working prototype | `streamlit run app.py` after installing `.[demo]` |
+| Hosted demo | Not published yet |
+| 2–5 minute video | Not published yet; use [the recording script](video-script.md) |
+| API documentation | [FortyGuard API integration](api-integration.md) |
+
+## Tagline
+
+Heat-aware field operations planning powered by FortyGuard hyperlocal
+temperature intelligence.
+
+## Short summary
+
+HeatOps converts FortyGuard's location- and time-specific temperature data into
+an explainable daily schedule for field teams. A deterministic constraint
+solver keeps job windows, crew shifts, skills, and non-overlap as hard rules,
+then lets an operator choose the balance between operational Heat Load and
+priority-weighted delay. The demo compares every result against a fair,
+temperature-unaware baseline and explains exactly which jobs moved and why.
+
+## Problem
+
+Field dispatch decisions are usually driven by deadlines, staffing, and route
+feasibility. Heat can vary across nearby work sites and throughout a shift, but
+that hyperlocal signal is difficult to translate into a concrete plan. A map
+alone tells an operator where heat exists; it does not answer which task should
+move, to what time, and at what operational cost.
+
+## Solution
+
+HeatOps closes that last-mile decision gap:
+
+1. It requests FortyGuard heatmaps for the field-work AOI and time horizon.
+2. It matches each job coordinate to the returned temperature tiles.
+3. It validates and caches a complete job-by-time temperature matrix.
+4. It generates feasible schedules with OR-Tools CP-SAT.
+5. It compares Operations-first, Balanced, Heat-first, and custom policies.
+6. It renders before/after timelines, a map, temperature curves, trade-offs,
+ headline metrics, and job-level explanations.
+
+The live path has bounded retries, polling, validation, and cache fallback. The
+default demo uses a traceable FortyGuard snapshot so judging remains
+reproducible without an API key or network dependency.
+
+## Demonstrated impact
+
+For the bundled Phoenix utility scenario, the Heat-first policy changes the
+schedule as follows:
+
+| Metric | Operations-first baseline | HeatOps Heat-first |
+| --- | ---: | ---: |
+| Operational Heat Load Score | 40.38 | 39.18 |
+| Heat Load reduction | — | 2.97% |
+| Jobs moved | — | 3 of 5 |
+
+These are deterministic scenario results checked by the automated test suite.
+They are evidence for this prototype, not a claim that every schedule or city
+will achieve the same reduction. Heat Load is a transparent operational score,
+not a medical risk assessment.
+
+## Why it is innovative
+
+- It moves beyond heat visualization to a recommended, constraint-valid action.
+- Policy presets and a custom slider expose the heat-versus-delay decision
+ instead of burying it in an opaque score.
+- The baseline is scientifically fair: both schedules share the same solver and
+ constraints, and only the heat objective changes.
+- Explanations connect each moved job to its time, temperature, and Heat Load
+ delta.
+- Source banners, checksums, validation, and fallback make the demo's data
+ provenance visible.
+
+## Technical quality evidence
+
+- Layered Python package with domain, integration, optimization, evaluation,
+ presentation, CLI, and Streamlit boundaries.
+- Deterministic CP-SAT optimization with explicit constraints and stable
+ tie-breaking.
+- Typed dataclasses and centralized scheduler configuration.
+- Resilient asynchronous FortyGuard client and complete-matrix validation.
+- Unit, integration, scenario, CLI, presentation, and Streamlit smoke tests.
+- GitHub Actions gates on Python 3.11 and 3.12 for lint, format, tests, compile,
+ and installed-CLI smoke behavior.
+
+## FortyGuard API usage
+
+HeatOps sends a padded GeoJSON AOI, date, time, and granularity to
+`POST /v1/heatmap`, obtains an activity ID, and polls
+`GET /v1/status/{activity_id}`. On completion it reads `map_data.features`,
+spatially maps temperatures to jobs, and validates every requested job/time
+value before scheduling. Full request, response, retry, cache, and provenance
+details are in [FortyGuard API integration](api-integration.md).
+
+## Scope and responsible claims
+
+The submission is a single-crew daily-planning prototype. It does not implement
+multi-crew routing, real-time travel, medical guidance, or production identity
+and persistence. Those omissions keep the hackathon story focused and the demo
+fully testable.
+
+## Final submission checklist
+
+- [x] Working demo/prototype is runnable from the repository.
+- [x] Public code repository URL is recorded.
+- [x] Written project summary, solution, and impact are ready.
+- [x] FortyGuard API usage is documented.
+- [x] A 2–5 minute recording script and demo runbook are ready.
+- [ ] Hosted demo URL is added after deployment.
+- [ ] Final video is recorded, uploaded, and linked.
+- [ ] Submission form fields and team details are reviewed before sending.
+
+The machine-readable status and evidence values live in
+[`submission/heatops-submission.json`](../submission/heatops-submission.json).
diff --git a/docs/video-script.md b/docs/video-script.md
new file mode 100644
index 0000000..e680ce6
--- /dev/null
+++ b/docs/video-script.md
@@ -0,0 +1,94 @@
+# HeatOps 2–5 minute video script
+
+Target length: approximately 3 minutes 20 seconds. The speaker notes are
+written for a screen recording of `streamlit run app.py`.
+
+## 0:00–0:20 — Hook
+
+**On screen:** HeatOps title and Phoenix scenario.
+
+**Narration:**
+
+“Heat conditions can change by location and hour, but field schedules are still
+usually planned around deadlines alone. HeatOps turns FortyGuard's hyperlocal
+temperature intelligence into an explainable daily action: which job should
+move, when, and what trade-off that creates.”
+
+## 0:20–0:50 — Inputs and trust
+
+**On screen:** Data-source banner, controls, and temperature curves.
+
+**Narration:**
+
+“This demo schedules five Phoenix utility jobs for Crew Alpha. The temperature
+matrix comes from a traceable FortyGuard snapshot covering each job location
+from 8 a.m. through 7 p.m. Jobs, priorities, physical intensity, and crew skills
+are explicit demo assumptions. The banner always shows whether data is live,
+cached, or the bundled snapshot.”
+
+## 0:50–1:20 — Fair baseline
+
+**Action:** Select **Operations-first** and click **Optimize schedule**.
+
+**Narration:**
+
+“First, here is the operations-first baseline. It uses the same OR-Tools solver,
+time windows, shift, skills, and non-overlap constraints as HeatOps, but it does
+not use temperature to choose the schedule. That makes the comparison fair: we
+change one decision factor, not the rules.”
+
+## 1:20–2:05 — Heat-aware result
+
+**Action:** Select **Heat-first**, optimize, then point to metrics and timelines.
+
+**Narration:**
+
+“Now I switch to Heat-first. On this scenario, HeatOps reduces the operational
+Heat Load Score from 40.38 to 39.18—a 2.97 percent reduction—by moving three of
+five jobs. The timelines make the schedule change visible. Heat Load is a
+transparent planning score based on temperature above a 32-degree threshold,
+duration, and job intensity; it is not a medical risk assessment.”
+
+## 2:05–2:35 — Explainability
+
+**On screen:** Operational warning, map, and “Why the schedule changed.”
+
+**Narration:**
+
+“HeatOps does not hide the cost. It reports delay and idle-time trade-offs, and
+every job gets a plain-language explanation with its old and new time,
+temperature change, and Heat Load change. The location map and curves show why
+one citywide forecast would not be enough.”
+
+## 2:35–3:00 — Interactive policy
+
+**Action:** Select **Custom**, set Heat priority to 75%, optimize, and point to
+the trade-off chart.
+
+**Narration:**
+
+“Different operators have different constraints, so Custom mode exposes the
+policy. Here, 75 percent of the objective favors Heat Load and the remainder
+favors priority-weighted delay. The trade-off chart lets a dispatcher compare
+policies instead of accepting a black-box answer.”
+
+## 3:00–3:20 — Technical close
+
+**On screen:** Return to the headline metrics, then show the repository README.
+
+**Narration:**
+
+“Under the hood, HeatOps has a resilient asynchronous FortyGuard integration,
+complete data validation, cache and snapshot fallback, deterministic CP-SAT
+optimization, and automated tests across Python 3.11 and 3.12. HeatOps turns a
+heatmap into an operational decision that teams can inspect and act on.”
+
+## Recording notes
+
+- Keep the final export between 2 and 5 minutes.
+- Pause briefly after each optimization so charts finish rendering.
+- Do not show API keys, `.env`, Streamlit secrets, or browser developer tools.
+- If a live API call is included, record it after the deterministic main take
+ and keep the source banner visible.
+- Replace the missing video URL in `docs/submission.md` and
+ `submission/heatops-submission.json` only after upload succeeds.
diff --git a/pyproject.toml b/pyproject.toml
index c35f399..ef6f125 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,10 +24,10 @@ dev = [
"ruff>=0.6",
]
demo = [
- "altair>=5",
- "pandas>=2",
- "pydeck>=0.9",
- "streamlit>=1.37",
+ "altair>=5,<7",
+ "pandas>=2,<4",
+ "pydeck>=0.9,<1",
+ "streamlit>=1.62,<2",
]
[tool.setuptools.packages.find]
diff --git a/scripts/run_heatmap.py b/scripts/run_heatmap.py
index 7c7832a..43f1a1c 100644
--- a/scripts/run_heatmap.py
+++ b/scripts/run_heatmap.py
@@ -26,11 +26,9 @@
activity_id = client.create_heatmap(
polygon_aoi=polygon_aoi,
-
# Historical test first, so we don't hit the +12h forecast limit.
start_date="2026-08-24",
start_time="14:00",
-
granularity=100,
)
@@ -55,10 +53,7 @@
print("\nFirst heatmap feature:")
print(json.dumps(features[0], indent=2))
-matched_jobs = match_jobs_to_temperatures(
- jobs,
- features
-)
+matched_jobs = match_jobs_to_temperatures(jobs, features)
print("\nJob temperatures:")
@@ -66,15 +61,8 @@
temperature = job["temperature"]
if temperature is None:
- print(
- f'{job["id"]} | '
- f'{job["name"]} | '
- f'No matching temperature tile'
- )
+ print(f"{job['id']} | {job['name']} | No matching temperature tile")
else:
print(
- f'{job["id"]} | '
- f'{job["name"]} | '
- f'{temperature:.2f}°C | '
- f'Tile {job["tile_id"]}'
- )
\ No newline at end of file
+ f"{job['id']} | {job['name']} | {temperature:.2f}°C | Tile {job['tile_id']}"
+ )
diff --git a/src/heatops/domain/loaders.py b/src/heatops/domain/loaders.py
index 26ddca3..af19968 100644
--- a/src/heatops/domain/loaders.py
+++ b/src/heatops/domain/loaders.py
@@ -52,4 +52,4 @@ def load_temperature_matrix(path: str | Path) -> TemperatureMatrix:
path = Path(path)
with path.open("r", encoding="utf-8") as file:
- return json.load(file)
\ No newline at end of file
+ return json.load(file)
diff --git a/src/heatops/presentation.py b/src/heatops/presentation.py
new file mode 100644
index 0000000..6f1b4cf
--- /dev/null
+++ b/src/heatops/presentation.py
@@ -0,0 +1,429 @@
+from collections.abc import Mapping
+from dataclasses import dataclass
+from math import isfinite
+
+from heatops.domain.models import (
+ Job,
+ OptimizationResult,
+ OptimizationWeights,
+ TemperatureMatrix,
+)
+from heatops.domain.time_utils import minutes_to_time, time_to_minutes
+from heatops.evaluation.models import ScheduleComparison, ScheduleMetrics
+from heatops.optimization.presets import PRESETS
+
+MODE_LABELS = {
+ "operations_first": "Operations-first",
+ "balanced": "Balanced",
+ "heat_first": "Heat-first",
+}
+MODE_ORDER = tuple(MODE_LABELS)
+
+
+@dataclass(frozen=True)
+class TimelineRecord:
+ schedule: str
+ job_id: str
+ job_name: str
+ worker_id: str
+ start_minute: int
+ end_minute: int
+ start_time: str
+ end_time: str
+ duration_minutes: int
+ temperature_c: float
+ heat_load: float
+ priority: int
+
+
+@dataclass(frozen=True)
+class MapMarkerRecord:
+ schedule: str
+ job_id: str
+ job_name: str
+ latitude: float
+ longitude: float
+ start_time: str
+ temperature_c: float
+ heat_load: float
+ moved: bool
+
+
+@dataclass(frozen=True)
+class TemperatureCurveRecord:
+ job_id: str
+ job_name: str
+ time_label: str
+ minute: int
+ temperature_c: float
+
+
+@dataclass(frozen=True)
+class MetricCardRecord:
+ key: str
+ label: str
+ baseline_value: float | int | None
+ optimized_value: float | int | None
+ display_value: str
+ display_delta: str
+ unit: str
+ help_text: str
+
+
+@dataclass(frozen=True)
+class JobChangeRecord:
+ job_id: str
+ job_name: str
+ baseline_start: str
+ optimized_start: str
+ start_change: str
+ temperature_change: str
+ heat_load_change: str
+ moved: bool
+ explanation: str
+
+
+@dataclass(frozen=True)
+class TradeoffRecord:
+ mode: str
+ mode_label: str
+ heat_weight_percent: float
+ delay_weight_percent: float
+ total_heat_load: float
+ total_delay_hours: float
+ priority_weighted_delay_hours: float
+ moved_jobs: int
+
+
+def _jobs_by_id(jobs: list[Job]) -> dict[str, Job]:
+ jobs_by_id = {job.id: job for job in jobs}
+
+ if len(jobs_by_id) != len(jobs):
+ raise ValueError("Job ids must be unique.")
+
+ return jobs_by_id
+
+
+def _job_for(job_id: str, jobs_by_id: Mapping[str, Job]) -> Job:
+ try:
+ return jobs_by_id[job_id]
+ except KeyError as error:
+ raise ValueError(f"Schedule contains unknown job {job_id!r}.") from error
+
+
+def build_timeline_records(
+ result: OptimizationResult,
+ jobs: list[Job],
+ schedule_label: str,
+) -> tuple[TimelineRecord, ...]:
+ """Convert a schedule into deterministic, chart-library-neutral records."""
+
+ if not result.assignments:
+ return ()
+
+ jobs_by_id = _jobs_by_id(jobs)
+ records = []
+
+ for assignment in sorted(
+ result.assignments,
+ key=lambda item: (item.start_minute, item.job_id),
+ ):
+ job = _job_for(assignment.job_id, jobs_by_id)
+ records.append(
+ TimelineRecord(
+ schedule=schedule_label,
+ job_id=job.id,
+ job_name=job.name,
+ worker_id=assignment.worker_id,
+ start_minute=assignment.start_minute,
+ end_minute=assignment.end_minute,
+ start_time=minutes_to_time(assignment.start_minute),
+ end_time=minutes_to_time(assignment.end_minute),
+ duration_minutes=assignment.end_minute - assignment.start_minute,
+ temperature_c=assignment.temperature_c,
+ heat_load=assignment.heat_load,
+ priority=job.priority,
+ )
+ )
+
+ return tuple(records)
+
+
+def build_map_marker_records(
+ result: OptimizationResult,
+ jobs: list[Job],
+ schedule_label: str,
+ moved_job_ids: set[str] | frozenset[str] | None = None,
+) -> tuple[MapMarkerRecord, ...]:
+ """Convert assignments into map markers with domain coordinates attached."""
+
+ if not result.assignments:
+ return ()
+
+ jobs_by_id = _jobs_by_id(jobs)
+ moved_job_ids = moved_job_ids or set()
+ markers = []
+
+ for assignment in sorted(
+ result.assignments,
+ key=lambda item: (item.start_minute, item.job_id),
+ ):
+ job = _job_for(assignment.job_id, jobs_by_id)
+ markers.append(
+ MapMarkerRecord(
+ schedule=schedule_label,
+ job_id=job.id,
+ job_name=job.name,
+ latitude=job.latitude,
+ longitude=job.longitude,
+ start_time=minutes_to_time(assignment.start_minute),
+ temperature_c=assignment.temperature_c,
+ heat_load=assignment.heat_load,
+ moved=job.id in moved_job_ids,
+ )
+ )
+
+ return tuple(markers)
+
+
+def build_temperature_curve_records(
+ jobs: list[Job],
+ temperature_matrix: TemperatureMatrix,
+) -> tuple[TemperatureCurveRecord, ...]:
+ """Flatten the temperature matrix into stable job/time curve records."""
+
+ if not jobs:
+ return ()
+
+ _jobs_by_id(jobs)
+ records = []
+
+ for job in sorted(jobs, key=lambda item: item.id):
+ try:
+ temperatures = temperature_matrix[job.id]["temperatures"]
+ except (KeyError, TypeError) as error:
+ raise ValueError(f"Temperature matrix is missing job {job.id}.") from error
+
+ for time_label, temperature in sorted(
+ temperatures.items(),
+ key=lambda item: time_to_minutes(item[0]),
+ ):
+ if (
+ isinstance(temperature, bool)
+ or not isinstance(temperature, (int, float))
+ or not isfinite(temperature)
+ ):
+ raise ValueError(
+ f"Temperature for {job.id} at {time_label} must be finite."
+ )
+
+ records.append(
+ TemperatureCurveRecord(
+ job_id=job.id,
+ job_name=job.name,
+ time_label=time_label,
+ minute=time_to_minutes(time_label),
+ temperature_c=float(temperature),
+ )
+ )
+
+ return tuple(records)
+
+
+def _formatted_delta(
+ baseline: float | None,
+ optimized: float | None,
+ unit: str,
+ decimals: int,
+) -> str:
+ if baseline is None or optimized is None:
+ return "N/A"
+
+ delta = optimized - baseline
+ suffix = f" {unit}" if unit else ""
+ return f"{delta:+.{decimals}f}{suffix} vs baseline"
+
+
+def _metric_card(
+ *,
+ key: str,
+ label: str,
+ baseline: float | None,
+ optimized: float | None,
+ unit: str,
+ decimals: int,
+ help_text: str,
+) -> MetricCardRecord:
+ if optimized is None:
+ display_value = "N/A"
+ else:
+ suffix = f" {unit}" if unit else ""
+ display_value = f"{optimized:.{decimals}f}{suffix}"
+
+ return MetricCardRecord(
+ key=key,
+ label=label,
+ baseline_value=baseline,
+ optimized_value=optimized,
+ display_value=display_value,
+ display_delta=_formatted_delta(
+ baseline,
+ optimized,
+ unit,
+ decimals,
+ ),
+ unit=unit,
+ help_text=help_text,
+ )
+
+
+def build_metric_card_records(
+ comparison: ScheduleComparison,
+) -> tuple[MetricCardRecord, ...]:
+ """Format the central baseline/optimized evidence for UI metric cards."""
+
+ baseline = comparison.baseline_metrics
+ optimized = comparison.optimized_metrics
+
+ return (
+ MetricCardRecord(
+ key="heat_load_reduction",
+ label="Heat Load reduction",
+ baseline_value=baseline.total_heat_load,
+ optimized_value=optimized.total_heat_load,
+ display_value=f"{comparison.heat_load_reduction_percent:.1f}%",
+ display_delta=(
+ f"{optimized.total_heat_load - baseline.total_heat_load:+.2f} Heat Load"
+ ),
+ unit="%",
+ help_text="Relative change from the operations-first baseline.",
+ ),
+ _metric_card(
+ key="heat_load",
+ label="Operational Heat Load",
+ baseline=baseline.total_heat_load,
+ optimized=optimized.total_heat_load,
+ unit="",
+ decimals=2,
+ help_text="Operational planning score; not a medical risk assessment.",
+ ),
+ _metric_card(
+ key="peak_temperature",
+ label="Peak scheduled temperature",
+ baseline=baseline.peak_temperature_c,
+ optimized=optimized.peak_temperature_c,
+ unit="C",
+ decimals=2,
+ help_text="Highest modeled temperature encountered by a scheduled job.",
+ ),
+ _metric_card(
+ key="minutes_above_threshold",
+ label="Minutes above threshold",
+ baseline=baseline.minutes_above_threshold,
+ optimized=optimized.minutes_above_threshold,
+ unit="min",
+ decimals=0,
+ help_text="Scheduled minutes above the configured heat threshold.",
+ ),
+ _metric_card(
+ key="priority_weighted_delay",
+ label="Priority-weighted delay",
+ baseline=baseline.priority_weighted_delay_hours,
+ optimized=optimized.priority_weighted_delay_hours,
+ unit="h",
+ decimals=2,
+ help_text="Delay hours weighted by each job's operational priority.",
+ ),
+ _metric_card(
+ key="idle_time",
+ label="Crew idle time",
+ baseline=baseline.idle_minutes,
+ optimized=optimized.idle_minutes,
+ unit="min",
+ decimals=0,
+ help_text="Unscheduled gaps between consecutive crew assignments.",
+ ),
+ )
+
+
+def build_job_change_records(
+ comparison: ScheduleComparison,
+ jobs: list[Job],
+) -> tuple[JobChangeRecord, ...]:
+ """Format deterministic per-job explanations without changing their meaning."""
+
+ jobs_by_id = _jobs_by_id(jobs)
+ records = []
+
+ for change in sorted(comparison.job_changes, key=lambda item: item.job_id):
+ job = _job_for(change.job_id, jobs_by_id)
+ records.append(
+ JobChangeRecord(
+ job_id=job.id,
+ job_name=job.name,
+ baseline_start=minutes_to_time(change.baseline_start_minute),
+ optimized_start=minutes_to_time(change.optimized_start_minute),
+ start_change=f"{change.start_delta_minutes:+d} min",
+ temperature_change=f"{change.temperature_delta_c:+.2f} C",
+ heat_load_change=f"{change.heat_load_delta:+.2f}",
+ moved=change.moved,
+ explanation=change.explanation,
+ )
+ )
+
+ return tuple(records)
+
+
+def _ordered_modes(comparisons: Mapping[str, ScheduleComparison]) -> list[str]:
+ known = [mode for mode in MODE_ORDER if mode in comparisons]
+ unknown = sorted(set(comparisons) - set(MODE_ORDER))
+ return [*known, *unknown]
+
+
+def build_tradeoff_records(
+ comparisons: Mapping[str, ScheduleComparison],
+ weights_by_mode: Mapping[str, OptimizationWeights] = PRESETS,
+) -> tuple[TradeoffRecord, ...]:
+ """Convert already-computed mode comparisons into tradeoff-chart records."""
+
+ records = []
+
+ for mode in _ordered_modes(comparisons):
+ try:
+ weights = weights_by_mode[mode]
+ except KeyError as error:
+ raise ValueError(
+ f"Optimization weights are missing for mode {mode!r}."
+ ) from error
+
+ heat_weight, delay_weight = weights.normalized()
+ comparison = comparisons[mode]
+ metrics = comparison.optimized_metrics
+ records.append(
+ TradeoffRecord(
+ mode=mode,
+ mode_label=MODE_LABELS.get(mode, mode.replace("_", " ").title()),
+ heat_weight_percent=heat_weight * 100,
+ delay_weight_percent=delay_weight * 100,
+ total_heat_load=metrics.total_heat_load,
+ total_delay_hours=metrics.total_delay_hours,
+ priority_weighted_delay_hours=metrics.priority_weighted_delay_hours,
+ moved_jobs=comparison.moved_jobs,
+ )
+ )
+
+ return tuple(records)
+
+
+def empty_schedule_metrics() -> ScheduleMetrics:
+ """Return a safe all-zero metric set for empty presentation states."""
+
+ return ScheduleMetrics(
+ total_heat_load=0.0,
+ peak_temperature_c=None,
+ minutes_above_threshold=0,
+ total_delay_hours=0.0,
+ priority_weighted_delay_hours=0.0,
+ idle_minutes=0,
+ scheduled_jobs=0,
+ )
diff --git a/submission/heatops-submission.json b/submission/heatops-submission.json
new file mode 100644
index 0000000..d389ebd
--- /dev/null
+++ b/submission/heatops-submission.json
@@ -0,0 +1,44 @@
+{
+ "project_name": "HeatOps",
+ "tagline": "Heat-aware field operations planning powered by FortyGuard hyperlocal temperature intelligence.",
+ "repository_url": "https://github.com/MichelleHan7/HeatOps",
+ "demo": {
+ "command": "streamlit run app.py",
+ "hosted_url": null
+ },
+ "video": {
+ "required_duration_minutes": [
+ 2,
+ 5
+ ],
+ "url": null,
+ "script": "docs/video-script.md"
+ },
+ "documentation": {
+ "submission": "docs/submission.md",
+ "architecture": "docs/architecture.md",
+ "demo_guide": "docs/demo-guide.md",
+ "fortyguard_api": "docs/api-integration.md"
+ },
+ "scenario_evidence": {
+ "scenario_id": "phoenix-demo",
+ "temperature_provider": "FortyGuard",
+ "temperature_data_date": "2026-08-24",
+ "preset": "heat_first",
+ "heat_threshold_c": 32.0,
+ "baseline_heat_load": 40.38,
+ "optimized_heat_load": 39.18,
+ "heat_load_reduction_percent": 2.97,
+ "jobs_moved": 3,
+ "jobs_total": 5,
+ "claim_scope": "Deterministic Phoenix demo scenario evidence; not a universal outcome or medical risk assessment."
+ },
+ "deliverable_status": {
+ "working_prototype": "ready",
+ "code_repository": "ready",
+ "written_summary": "ready",
+ "fortyguard_api_documentation": "ready",
+ "hosted_demo": "pending",
+ "video": "pending"
+ }
+}
diff --git a/tests/test_app_smoke.py b/tests/test_app_smoke.py
new file mode 100644
index 0000000..0177d02
--- /dev/null
+++ b/tests/test_app_smoke.py
@@ -0,0 +1,153 @@
+import sys
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+
+import pytest
+from streamlit.testing.v1 import AppTest
+
+from heatops.integrations.fortyguard import FortyGuardAPIError
+
+ROOT = Path(__file__).resolve().parents[1]
+APP_PATH = ROOT / "app.py"
+APP_SPEC = spec_from_file_location("heatops_demo_app", APP_PATH)
+assert APP_SPEC is not None and APP_SPEC.loader is not None
+app = module_from_spec(APP_SPEC)
+sys.modules[APP_SPEC.name] = app
+APP_SPEC.loader.exec_module(app)
+
+
+def test_demo_scenario_loads_without_api_key(monkeypatch):
+ monkeypatch.delenv("FORTYGUARD_API_KEY", raising=False)
+
+ scenario = app.load_demo_scenario()
+
+ assert len(scenario.jobs) == 5
+ assert scenario.selected_worker.id == "CREW-001"
+ assert scenario.metadata["temperature_data"]["provider"] == "FortyGuard"
+
+
+@pytest.mark.parametrize(
+ ("label", "expected_key", "expected_weights"),
+ [
+ ("Operations-first", "operations_first", (0.0, 1.0)),
+ ("Balanced", "balanced", (0.5, 0.5)),
+ ("Heat-first", "heat_first", (1.0, 0.0)),
+ ("Custom", "custom", (0.8, 0.2)),
+ ],
+)
+def test_dashboard_modes_and_slider_resolve_to_expected_weights(
+ label,
+ expected_key,
+ expected_weights,
+):
+ mode_key, weights = app.resolve_weights(label, 80)
+
+ assert mode_key == expected_key
+ assert weights.normalized() == pytest.approx(expected_weights)
+
+
+def test_dashboard_state_uses_shared_evaluation_metrics():
+ scenario = app.load_demo_scenario()
+
+ state = app.build_dashboard_state(
+ scenario,
+ scenario.temperature_matrix,
+ "Heat-first",
+ 50,
+ )
+
+ assert state.mode_key == "heat_first"
+ assert state.comparison is state.preset_comparisons["heat_first"]
+ assert state.comparison.optimized_metrics.total_heat_load == pytest.approx(
+ state.comparison.optimized_result.total_heat_load
+ )
+ assert state.comparison.optimized_metrics.total_heat_load < (
+ state.comparison.baseline_metrics.total_heat_load
+ )
+ assert [record.mode for record in state.tradeoff_records] == [
+ "operations_first",
+ "balanced",
+ "heat_first",
+ ]
+
+
+def test_custom_dashboard_state_adds_tradeoff_point():
+ scenario = app.load_demo_scenario()
+
+ state = app.build_dashboard_state(
+ scenario,
+ scenario.temperature_matrix,
+ "Custom",
+ 80,
+ )
+
+ assert state.weights.heat == 0.8
+ assert state.weights.delay == pytest.approx(0.2)
+ assert state.tradeoff_records[-1].mode == "custom"
+ assert state.tradeoff_records[-1].heat_weight_percent == 80
+
+
+def test_live_failure_falls_back_without_leaking_api_key(tmp_path, monkeypatch):
+ secret = "super-secret-key"
+ monkeypatch.setenv("FORTYGUARD_API_KEY", secret)
+ scenario = app.load_demo_scenario()
+
+ class FailingClient:
+ def create_heatmap(self, **kwargs):
+ raise FortyGuardAPIError(f"request failed with credential {secret}")
+
+ def wait_for_result(self, *args, **kwargs):
+ raise AssertionError("Polling should not run after create failure.")
+
+ source = app.resolve_temperature_source(
+ scenario,
+ True,
+ client=FailingClient(),
+ cache_dir=tmp_path,
+ )
+
+ assert source.source == "snapshot"
+ assert source.matrix == scenario.temperature_matrix
+ assert "unavailable" in source.detail
+ assert secret not in source.detail
+ assert secret not in source.label
+
+
+def test_missing_scenario_has_readable_error(tmp_path):
+ with pytest.raises(app.ScenarioLoadError, match="Could not load"):
+ app.load_demo_scenario(tmp_path / "missing")
+
+
+def test_streamlit_app_smoke_renders_cached_demo_without_secrets(monkeypatch):
+ monkeypatch.delenv("FORTYGUARD_API_KEY", raising=False)
+ dashboard = AppTest.from_file(APP_PATH, default_timeout=30).run()
+
+ assert not dashboard.exception
+ assert dashboard.title[0].value == "HeatOps"
+ assert dashboard.selectbox[0].options == list(app.MODE_OPTIONS)
+ assert dashboard.selectbox[0].value == "Balanced"
+ assert dashboard.slider[0].disabled is True
+ assert dashboard.button[0].label == "Optimize schedule"
+ assert len(dashboard.metric) == 6
+ assert dashboard.metric[0].label == "Heat Load reduction"
+ assert any("Bundled FortyGuard snapshot" in item.value for item in dashboard.info)
+ assert any(
+ "Baseline vs optimized timeline" in item.value for item in dashboard.subheader
+ )
+
+
+def test_streamlit_app_can_optimize_custom_slider_mode(monkeypatch):
+ monkeypatch.delenv("FORTYGUARD_API_KEY", raising=False)
+ dashboard = AppTest.from_file(APP_PATH, default_timeout=30).run()
+ dashboard.selectbox[0].select("Custom").run()
+
+ assert dashboard.slider[0].disabled is False
+
+ dashboard.slider[0].set_value(80).run()
+ dashboard.button[0].click().run()
+ state = dashboard.session_state["dashboard_state"]
+
+ assert not dashboard.exception
+ assert state.mode_key == "custom"
+ assert state.weights.normalized() == pytest.approx((0.8, 0.2))
+ assert len(dashboard.metric) == 6
diff --git a/tests/test_loaders.py b/tests/test_loaders.py
index 069b4a2..1ed9ac3 100644
--- a/tests/test_loaders.py
+++ b/tests/test_loaders.py
@@ -56,4 +56,4 @@ def test_load_workers(tmp_path):
assert len(workers) == 1
assert workers[0].id == "CREW-001"
assert workers[0].skills == ("inspection",)
- assert workers[0].acclimatization == 1.0
\ No newline at end of file
+ assert workers[0].acclimatization == 1.0
diff --git a/tests/test_presentation.py b/tests/test_presentation.py
new file mode 100644
index 0000000..92b507c
--- /dev/null
+++ b/tests/test_presentation.py
@@ -0,0 +1,264 @@
+from dataclasses import replace
+
+import pytest
+
+from heatops.domain.models import (
+ Job,
+ OptimizationResult,
+ OptimizationWeights,
+ ScheduleAssignment,
+)
+from heatops.evaluation.models import JobChange, ScheduleComparison, ScheduleMetrics
+from heatops.presentation import (
+ build_job_change_records,
+ build_map_marker_records,
+ build_metric_card_records,
+ build_temperature_curve_records,
+ build_timeline_records,
+ build_tradeoff_records,
+ empty_schedule_metrics,
+)
+
+
+def _jobs():
+ return [
+ Job(
+ id="JOB-B",
+ name="Pole inspection",
+ latitude=33.46,
+ longitude=-112.06,
+ duration_minutes=45,
+ earliest_start="08:00",
+ deadline="12:00",
+ priority=1,
+ ),
+ Job(
+ id="JOB-A",
+ name="Transformer inspection",
+ latitude=33.45,
+ longitude=-112.07,
+ duration_minutes=60,
+ earliest_start="08:00",
+ deadline="12:00",
+ priority=3,
+ ),
+ ]
+
+
+def _assignment(job_id, start, duration, temperature, heat):
+ return ScheduleAssignment(
+ job_id=job_id,
+ worker_id="CREW-1",
+ start_minute=start,
+ end_minute=start + duration,
+ temperature_c=temperature,
+ heat_load=heat,
+ )
+
+
+def _result(*assignments, status="OPTIMAL"):
+ return OptimizationResult(
+ assignments=assignments,
+ status=status,
+ objective_value=0,
+ total_heat_load=sum(item.heat_load for item in assignments),
+ total_delay_hours=0,
+ )
+
+
+def _metrics(heat, peak, minutes, weighted_delay, idle):
+ return ScheduleMetrics(
+ total_heat_load=heat,
+ peak_temperature_c=peak,
+ minutes_above_threshold=minutes,
+ total_delay_hours=weighted_delay,
+ priority_weighted_delay_hours=weighted_delay,
+ idle_minutes=idle,
+ scheduled_jobs=2,
+ )
+
+
+def _comparison():
+ baseline = _result(
+ _assignment("JOB-A", 480, 60, 38.0, 8.0),
+ _assignment("JOB-B", 540, 45, 39.0, 7.0),
+ )
+ optimized = _result(
+ _assignment("JOB-B", 480, 45, 36.0, 5.0),
+ _assignment("JOB-A", 600, 60, 34.0, 4.0),
+ )
+ changes = (
+ JobChange(
+ job_id="JOB-A",
+ baseline_start_minute=480,
+ optimized_start_minute=600,
+ start_delta_minutes=120,
+ baseline_temperature_c=38.0,
+ optimized_temperature_c=34.0,
+ temperature_delta_c=-4.0,
+ baseline_heat_load=8.0,
+ optimized_heat_load=4.0,
+ heat_load_delta=-4.0,
+ moved=True,
+ explanation="JOB-A moved later and reduced operational Heat Load.",
+ ),
+ JobChange(
+ job_id="JOB-B",
+ baseline_start_minute=540,
+ optimized_start_minute=480,
+ start_delta_minutes=-60,
+ baseline_temperature_c=39.0,
+ optimized_temperature_c=36.0,
+ temperature_delta_c=-3.0,
+ baseline_heat_load=7.0,
+ optimized_heat_load=5.0,
+ heat_load_delta=-2.0,
+ moved=True,
+ explanation="JOB-B moved earlier and reduced operational Heat Load.",
+ ),
+ )
+ return ScheduleComparison(
+ baseline_result=baseline,
+ optimized_result=optimized,
+ baseline_metrics=_metrics(15.0, 39.0, 105, 1.0, 0),
+ optimized_metrics=_metrics(9.0, 36.0, 60, 4.0, 75),
+ heat_load_reduction_percent=40.0,
+ moved_jobs=2,
+ job_changes=changes,
+ )
+
+
+def test_timeline_records_are_chronological_and_format_times():
+ result = _result(
+ _assignment("JOB-A", 600, 60, 34.0, 4.0),
+ _assignment("JOB-B", 480, 45, 36.0, 5.0),
+ )
+
+ records = build_timeline_records(result, _jobs(), "HeatOps")
+
+ assert [record.job_id for record in records] == ["JOB-B", "JOB-A"]
+ assert records[0].start_time == "08:00"
+ assert records[0].end_time == "08:45"
+ assert records[0].schedule == "HeatOps"
+ assert records[1].priority == 3
+
+
+def test_map_markers_propagate_coordinates_and_moved_state():
+ markers = build_map_marker_records(
+ _comparison().optimized_result,
+ _jobs(),
+ "HeatOps",
+ {"JOB-A"},
+ )
+
+ assert markers[0].job_id == "JOB-B"
+ assert markers[0].latitude == 33.46
+ assert markers[0].longitude == -112.06
+ assert markers[0].moved is False
+ assert markers[1].moved is True
+
+
+def test_temperature_curves_use_stable_job_and_time_order():
+ matrix = {
+ "JOB-A": {
+ "name": "Transformer inspection",
+ "temperatures": {"09:00": 35.0, "08:00": 34.0},
+ },
+ "JOB-B": {
+ "name": "Pole inspection",
+ "temperatures": {"09:00": 36.0, "08:00": 35.0},
+ },
+ }
+
+ records = build_temperature_curve_records(_jobs(), matrix)
+
+ assert [(record.job_id, record.time_label) for record in records] == [
+ ("JOB-A", "08:00"),
+ ("JOB-A", "09:00"),
+ ("JOB-B", "08:00"),
+ ("JOB-B", "09:00"),
+ ]
+ assert records[0].minute == 480
+
+
+def test_metric_cards_have_consistent_labels_values_and_deltas():
+ cards = {card.key: card for card in build_metric_card_records(_comparison())}
+
+ assert cards["heat_load_reduction"].display_value == "40.0%"
+ assert cards["heat_load_reduction"].display_delta == "-6.00 Heat Load"
+ assert cards["heat_load"].display_value == "9.00"
+ assert cards["heat_load"].display_delta == "-6.00 vs baseline"
+ assert cards["peak_temperature"].display_value == "36.00 C"
+ assert cards["minutes_above_threshold"].display_delta == ("-45 min vs baseline")
+ assert "not a medical risk assessment" in cards["heat_load"].help_text
+
+
+def test_metric_cards_handle_empty_peak_without_crashing():
+ empty = empty_schedule_metrics()
+ comparison = replace(
+ _comparison(),
+ baseline_metrics=empty,
+ optimized_metrics=empty,
+ )
+ cards = {card.key: card for card in build_metric_card_records(comparison)}
+
+ assert cards["peak_temperature"].display_value == "N/A"
+ assert cards["peak_temperature"].display_delta == "N/A"
+
+
+def test_job_change_records_attach_names_and_format_changes():
+ records = build_job_change_records(_comparison(), _jobs())
+
+ assert [record.job_id for record in records] == ["JOB-A", "JOB-B"]
+ assert records[0].job_name == "Transformer inspection"
+ assert records[0].baseline_start == "08:00"
+ assert records[0].optimized_start == "10:00"
+ assert records[0].temperature_change == "-4.00 C"
+
+
+def test_tradeoff_records_follow_preset_order_and_keep_numeric_metrics():
+ comparison = _comparison()
+ comparisons = {
+ "heat_first": comparison,
+ "operations_first": replace(comparison, moved_jobs=0),
+ "balanced": replace(comparison, moved_jobs=1),
+ }
+
+ records = build_tradeoff_records(comparisons)
+
+ assert [record.mode for record in records] == [
+ "operations_first",
+ "balanced",
+ "heat_first",
+ ]
+ assert records[0].mode_label == "Operations-first"
+ assert records[0].heat_weight_percent == 0
+ assert records[2].heat_weight_percent == 100
+ assert records[1].total_heat_load == 9.0
+
+
+def test_tradeoff_records_require_weights_for_custom_mode():
+ with pytest.raises(ValueError, match="weights are missing"):
+ build_tradeoff_records({"custom": _comparison()})
+
+ records = build_tradeoff_records(
+ {"custom": _comparison()},
+ {"custom": OptimizationWeights(heat=0.75, delay=0.25)},
+ )
+ assert records[0].mode_label == "Custom"
+ assert records[0].heat_weight_percent == 75
+
+
+def test_empty_or_infeasible_schedule_returns_empty_chart_records():
+ result = _result(status="INFEASIBLE")
+
+ assert build_timeline_records(result, _jobs(), "HeatOps") == ()
+ assert build_map_marker_records(result, _jobs(), "HeatOps") == ()
+ assert build_temperature_curve_records([], {}) == ()
+
+
+def test_unknown_schedule_job_has_readable_error():
+ result = _result(_assignment("UNKNOWN", 480, 60, 35.0, 5.0))
+
+ with pytest.raises(ValueError, match="unknown job 'UNKNOWN'"):
+ build_timeline_records(result, _jobs(), "HeatOps")
diff --git a/tests/test_submission_artifacts.py b/tests/test_submission_artifacts.py
new file mode 100644
index 0000000..b59b92b
--- /dev/null
+++ b/tests/test_submission_artifacts.py
@@ -0,0 +1,95 @@
+import json
+from pathlib import Path
+
+import pytest
+
+from heatops.domain.config import SchedulerConfig
+from heatops.domain.loaders import load_jobs, load_temperature_matrix, load_workers
+from heatops.evaluation.service import compare_schedules
+from heatops.optimization.presets import HEAT_FIRST
+
+ROOT = Path(__file__).resolve().parents[1]
+SCENARIO = ROOT / "data" / "scenarios" / "phoenix-demo"
+MANIFEST = ROOT / "submission" / "heatops-submission.json"
+
+
+def _manifest() -> dict:
+ return json.loads(MANIFEST.read_text(encoding="utf-8"))
+
+
+def test_submission_package_has_every_local_artifact():
+ manifest = _manifest()
+ paths = {
+ "README.md",
+ manifest["video"]["script"],
+ *manifest["documentation"].values(),
+ }
+
+ assert all((ROOT / path).is_file() for path in paths)
+ assert (ROOT / "app.py").is_file()
+ assert manifest["demo"]["command"] == "streamlit run app.py"
+
+
+def test_submission_evidence_matches_reproducible_scenario():
+ manifest = _manifest()
+ evidence = manifest["scenario_evidence"]
+ jobs = load_jobs(SCENARIO / "jobs.json")
+ workers = load_workers(SCENARIO / "workers.json")
+ matrix = load_temperature_matrix(SCENARIO / "temperature_matrix.json")
+ metadata = json.loads((SCENARIO / "metadata.json").read_text(encoding="utf-8"))
+ worker = next(
+ item
+ for item in workers
+ if item.id == metadata["operational_assumptions"]["selected_worker_id"]
+ )
+ comparison = compare_schedules(jobs, matrix, worker=worker, weights=HEAT_FIRST)
+
+ assert evidence["scenario_id"] == metadata["scenario_id"]
+ assert evidence["temperature_provider"] == metadata["temperature_data"]["provider"]
+ assert (
+ evidence["temperature_data_date"] == metadata["temperature_data"]["data_date"]
+ )
+ assert evidence["heat_threshold_c"] == SchedulerConfig().heat_threshold_c
+ assert (
+ metadata["operational_assumptions"]["heat_threshold_c"]
+ == SchedulerConfig().heat_threshold_c
+ )
+ assert evidence["baseline_heat_load"] == round(
+ comparison.baseline_metrics.total_heat_load, 2
+ )
+ assert evidence["optimized_heat_load"] == round(
+ comparison.optimized_metrics.total_heat_load, 2
+ )
+ assert evidence["heat_load_reduction_percent"] == pytest.approx(
+ round(comparison.heat_load_reduction_percent, 2)
+ )
+ assert evidence["jobs_moved"] == comparison.moved_jobs
+ assert evidence["jobs_total"] == len(jobs)
+
+
+def test_submission_status_does_not_claim_unpublished_links_are_ready():
+ manifest = _manifest()
+ status = manifest["deliverable_status"]
+
+ assert manifest["repository_url"] == "https://github.com/MichelleHan7/HeatOps"
+ assert manifest["demo"]["hosted_url"] is None
+ assert manifest["video"]["url"] is None
+ assert status["working_prototype"] == "ready"
+ assert status["hosted_demo"] == "pending"
+ assert status["video"] == "pending"
+ assert manifest["video"]["required_duration_minutes"] == [2, 5]
+
+
+def test_ci_workflow_covers_quality_and_demo_smoke_checks():
+ workflow = (ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
+
+ for required in (
+ 'python-version: ["3.11", "3.12"]',
+ 'python -m pip install -e ".[demo,dev]"',
+ "ruff check .",
+ "ruff format --check .",
+ "pytest -q",
+ "python -m compileall",
+ "heatops-evaluate --mode heat_first --format json",
+ ):
+ assert required in workflow