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 + +[![CI](https://github.com/MichelleHan7/HeatOps/actions/workflows/ci.yml/badge.svg)](https://github.com/MichelleHan7/HeatOps/actions/workflows/ci.yml) +[![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-3776AB.svg)](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