"""Streamlit testing console for the Reno Scheduling Engine (Phase 1). Run the API first: python -m uvicorn app.api.app:app --port 8000 Then: streamlit run streamlit_app.py It exercises the live HTTP API (create project, add BOQ items, add manual dependencies, compute a baseline) and visualises the result as a Gantt chart and a dependency DAG, with explanatory details for every concept. """ from __future__ import annotations import datetime as dt import httpx import pandas as pd import plotly.express as px import streamlit as st st.set_page_config(page_title="Reno Scheduling Engine — Tester", layout="wide") KIND_COLORS = { "PROCUREMENT": "#2563eb", "INSTALLATION": "#16a34a", "COMMISSIONING": "#9333ea", "INSPECTION": "#d97706", "CUSTOM": "#64748b", } # --------------------------------------------------------------------------- API helpers def api_base() -> str: return st.session_state.get("api_base", "http://127.0.0.1:8000") def _client() -> httpx.Client: return httpx.Client(base_url=api_base(), timeout=15) def api_get(path: str): with _client() as c: r = c.get(path) return r def api_post(path: str, body: dict): with _client() as c: r = c.post(path, json=body) return r def ok(r) -> bool: return 200 <= r.status_code < 300 def parse_iso(s: str) -> dt.datetime: return dt.datetime.fromisoformat(s.replace("Z", "+00:00")) def _demo_seed() -> None: """Create a demo project with a mix of BOQ items and a baseline.""" cats = api_get("/v1/categories").json() by_code = {c["code"]: c["id"] for c in cats} flooring = by_code.get("FLOORING", cats[0]["id"]) joinery = by_code.get("JOINERY", flooring) pid = api_post("/v1/projects", {"name": "Demo Villa", "planned_start": "2026-07-01"}).json()["id"] plan = [ (flooring, "Living room flooring", "SUPPLY_AND_APPLY", {"override_lead_time_days": 10}), (flooring, "Bathroom tiles", "SUPPLY", {}), (joinery, "Kitchen cabinetry", "SUPPLY_AND_APPLY", {}), (joinery, "Wardrobe install (client-supplied)", "APPLY", {}), (flooring, "Skirting", "APPLY", {}), ] for cat, name, ptype, meta in plan: api_post(f"/v1/projects/{pid}/boq-items", { "category_id": cat, "name": name, "quantity": 20, "uom": "sqm", "procurement_type": ptype, "procurement_responsibility": "CONTRACTOR", "is_material": ptype != "APPLY", "metadata": meta, }) api_post(f"/v1/projects/{pid}/schedule/baseline", {"start_date": "2026-07-01"}) # Stash under a NON-widget key; the selectbox key cannot be set after the # widget is instantiated. It is applied on the next run before the widget. st.session_state["_demo_pid"] = pid st.toast(f"Demo project #{pid} created with a baseline schedule") # --------------------------------------------------------------------------- sidebar with st.sidebar: st.title("⚙️ Scheduler Tester") st.session_state["api_base"] = st.text_input("API base URL", api_base()) health = None try: health = api_get("/v1/projects") except Exception as exc: # noqa: BLE001 st.error(f"API unreachable:\n{exc}") if health is not None and ok(health): st.success(f"API online · {len(health.json())} project(s)") elif health is not None: st.warning(f"API responded {health.status_code}") st.divider() st.caption( "Start the API with:\n\n`python -m uvicorn app.api.app:app --port 8000`\n\n" "Seed reference data:\n\n`python -m scripts.seed`" ) st.divider() st.subheader("Projects") projects = [] try: pr = api_get("/v1/projects") if ok(pr): projects = pr.json() except Exception: # noqa: BLE001 pass labels = {p["id"]: f"#{p['id']} · {p['name']} ({p['status']})" for p in projects} # Apply a pending selection (e.g. from "Seed a demo project") BEFORE the # widget is created — setting a widget key after instantiation is illegal. if "_demo_pid" in st.session_state: _pid = st.session_state.pop("_demo_pid") if _pid in [p["id"] for p in projects]: st.session_state["active_project"] = _pid if projects: selected = st.selectbox( "Active project", options=[p["id"] for p in projects], format_func=lambda i: labels.get(i, str(i)), key="active_project", ) else: st.info("No projects yet — create one below.") selected = None with st.form("new_project", clear_on_submit=True): st.markdown("**New project**") np_name = st.text_input("Name", "Sample Villa Renovation") np_start = st.date_input("Planned start", dt.date(2026, 7, 1)) if st.form_submit_button("Create project"): r = api_post("/v1/projects", {"name": np_name, "planned_start": np_start.isoformat()}) if ok(r): st.success(f"Created project #{r.json()['id']}") st.rerun() else: st.error(r.text) if st.button("🚀 Seed a demo project (5 BOQ items + baseline)"): _demo_seed() st.rerun() # --------------------------------------------------------------------------- header st.title("🏗️ Reno Scheduling Engine — Phase 1 Tester") st.caption( "Minimum-viable DAG scheduler: template-driven activity generation, cycle-safe " "dependencies, and a CPM forward pass against a 24/7 calendar stub." ) with st.expander("📖 How this works (read me)", expanded=False): st.markdown( """ **Pipeline:** `BOQ item → Rules Engine → activities + dependencies → DAG (cycle-checked) → CPM forward pass → schedule version` | `procurement_type` | activities generated | dependency | |---|---|---| | `SUPPLY` | 1 × **PROCUREMENT** | — | | `APPLY` | 1 × **INSTALLATION** | — | | `SUPPLY_AND_APPLY` | **PROCUREMENT** + **INSTALLATION** | FS, PROC → INSTALL, lag 0 | - **Clock** — `WALL` activities (procurement lead time) count calendar days (24/7); `WORKING` activities (installation effort) count working time against a **real calendar** (weekends, holidays, reduced-permit windows, per-contractor calendars). A project with no calendar attached falls back to 24/7. - **Dependencies** — `FS` finish→start, `SS` start→start, `FF` finish→finish, `SF` start→finish, each with an optional `lag_days`. Manual edges are rejected (HTTP 409) if they would create a **cycle** (Kahn's algorithm, inside the same DB transaction). - **Day 0 / Baseline** — an immutable snapshot (`schedule_versions` + `scheduled_activities`) with a deterministic `inputs_hash`. One BASELINE per project; calendar/pin/project changes write append-only FORECAST re-solves. See the **Day-0 Explainability** tab for a full derivation of every date. - **Pins** — hard PM constraints (`START_NO_EARLIER` / `START_NO_LATER` / `MUST_START_ON` / `MUST_FINISH_ON`); conflicts are flagged, not silently dropped. - **Not yet** — incremental Day-X reforecast & progress (Stage 4), backward pass / float (Stage 5). """ ) if not selected: st.stop() project_id = selected tab_boq, tab_deps, tab_sched, tab_explain, tab_raw = st.tabs( ["① BOQ items & activities", "② Dependencies", "③ Schedule & Gantt", "④ Day-0 Explainability", "⑤ Raw API"] ) # --------------------------------------------------------------------------- helpers def load_activities() -> list[dict]: r = api_get(f"/v1/projects/{project_id}/activities") return r.json() if ok(r) else [] def load_dependencies() -> list[dict]: r = api_get(f"/v1/projects/{project_id}/dependencies") return r.json() if ok(r) else [] def activity_label(a: dict) -> str: return f"#{a['id']} · {a['name']} [{a['kind']}]" # --------------------------------------------------------------------------- ① BOQ tab with tab_boq: st.subheader("Add a BOQ item") st.caption("On creation the Rules Engine auto-generates 1–2 activities + intra-item edges (no hardcoded if/else).") cats = [] cr = api_get("/v1/categories") if ok(cr): cats = cr.json() l1 = [c for c in cats if c["level"] == 1] with st.form("add_boq", clear_on_submit=False): c1, c2, c3 = st.columns(3) with c1: cat_id = st.selectbox( "Category", options=[c["id"] for c in (l1 or cats)], format_func=lambda i: next((f"{c['code']} — {c['name']}" for c in cats if c["id"] == i), str(i)), ) name = st.text_input("Name", "Living room flooring") with c2: ptype = st.selectbox("procurement_type", ["SUPPLY", "APPLY", "SUPPLY_AND_APPLY"], index=2) resp = st.selectbox("procurement_responsibility", ["CONTRACTOR", "CUSTOMER", "ORG"]) with c3: qty = st.number_input("Quantity", min_value=0.0, value=35.0) uom = st.text_input("UOM", "sqm") c4, c5, c6 = st.columns(3) with c4: is_material = st.checkbox("is_material", value=True) with c5: ov_lead = st.number_input("override_lead_time_days (0 = use template)", min_value=0.0, value=0.0) with c6: ov_effort = st.number_input("override_effort_days (0 = use template)", min_value=0.0, value=0.0) if st.form_submit_button("➕ Add BOQ item"): meta: dict = {} if ov_lead > 0: meta["override_lead_time_days"] = ov_lead if ov_effort > 0: meta["override_effort_days"] = ov_effort r = api_post(f"/v1/projects/{project_id}/boq-items", { "category_id": cat_id, "name": name, "quantity": qty, "uom": uom, "procurement_type": ptype, "procurement_responsibility": resp, "is_material": is_material, "metadata": meta, }) if r.status_code == 201: body = r.json() st.success( f"Created BOQ #{body['boq_item']['id']} → " f"{len(body['activities'])} activit{'y' if len(body['activities'])==1 else 'ies'}, " f"{len(body['dependencies'])} edge(s)" ) st.json(body, expanded=False) elif r.status_code == 422: st.error(f"NO_TEMPLATE — {r.json().get('message')}") else: st.error(r.text) st.divider() acts = load_activities() st.subheader(f"Activities in project #{project_id} ({len(acts)})") if acts: df = pd.DataFrame(acts)[ ["id", "boq_item_id", "kind", "name", "clock", "lead_time_days", "effort_days", "responsibility", "status"] ] st.dataframe(df, use_container_width=True, hide_index=True) else: st.info("No activities yet. Add a BOQ item above.") # --------------------------------------------------------------------------- ② Dependencies tab with tab_deps: st.subheader("Add a manual cross-item dependency") st.caption("Rejected with HTTP 409 if it would introduce a cycle (the insert is rolled back).") acts = load_activities() if len(acts) < 2: st.info("Add at least two activities first (tab ①).") else: opts = {activity_label(a): a["id"] for a in acts} with st.form("add_dep"): d1, d2, d3, d4 = st.columns([3, 3, 1, 1]) with d1: pred = st.selectbox("Predecessor", list(opts.keys())) with d2: succ = st.selectbox("Successor", list(opts.keys()), index=min(1, len(opts) - 1)) with d3: dep_type = st.selectbox("Type", ["FS", "SS", "FF", "SF"]) with d4: lag = st.number_input("Lag (d)", value=0.0) if st.form_submit_button("🔗 Add dependency"): r = api_post(f"/v1/projects/{project_id}/dependencies", { "predecessor_id": opts[pred], "successor_id": opts[succ], "dep_type": dep_type, "lag_days": lag, }) if r.status_code == 201: st.success(f"Edge #{r.json()['id']} created (origin MANUAL).") elif r.status_code == 409: st.error(f"CYCLE_DETECTED — path: {' → '.join(map(str, r.json()['cycle']))}") else: st.error(r.text) st.divider() deps = load_dependencies() st.subheader(f"Dependencies ({len(deps)})") if deps: name_by_id = {a["id"]: a["name"] for a in load_activities()} rows = [ { "id": d["id"], "predecessor": f"#{d['predecessor_id']} {name_by_id.get(d['predecessor_id'], '')}", "successor": f"#{d['successor_id']} {name_by_id.get(d['successor_id'], '')}", "type": d["dep_type"], "lag_days": d["lag_days"], "origin": d["origin"], } for d in deps ] st.dataframe(pd.DataFrame(rows), use_container_width=True, hide_index=True) else: st.info("No dependencies yet (template edges appear automatically for SUPPLY_AND_APPLY items).") # --------------------------------------------------------------------------- ③ Schedule tab with tab_sched: top = st.columns([1, 1, 2]) with top[0]: base_start = st.date_input("Baseline start_date", dt.date(2026, 7, 1), key="base_start") with top[1]: st.write("") st.write("") if st.button("📐 Compute baseline"): r = api_post(f"/v1/projects/{project_id}/schedule/baseline", {"start_date": base_start.isoformat()}) if r.status_code == 201: st.success(f"Baseline v{r.json()['schedule_version_id']} computed.") elif r.status_code == 409: st.warning(f"BASELINE_EXISTS — existing version #{r.json()['existing_version_id']}") else: st.error(r.text) versions = [] vr = api_get(f"/v1/projects/{project_id}/schedule-versions") if ok(vr): versions = vr.json() if not versions: st.info("No schedule version yet — compute a baseline above.") else: with top[2]: vid = st.selectbox( "Schedule version", options=[v["id"] for v in versions], format_func=lambda i: next( (f"v{v['id']} · {v['kind']} · {v['computed_at']}" for v in versions if v["id"] == i), str(i) ), ) detail = api_get(f"/v1/schedule-versions/{vid}").json() sched = detail["activities"] # Summary metrics finishes = [parse_iso(a["earliest_finish"]) for a in sched] starts = [parse_iso(a["earliest_start"]) for a in sched] m1, m2, m3, m4 = st.columns(4) m1.metric("Activities", len(sched)) m2.metric("Project start", min(starts).strftime("%Y-%m-%d") if starts else "—") m3.metric("Project finish", max(finishes).strftime("%Y-%m-%d %H:%M") if finishes else "—") if starts and finishes: m4.metric("Span (days)", f"{(max(finishes) - min(starts)).total_seconds()/86400:.1f}") st.caption(f"inputs_hash `{detail['inputs_hash'][:16]}…` · stats {detail.get('stats')}") # Gantt st.subheader("📊 Gantt") gdf = pd.DataFrame([ { "Activity": f"#{a['activity_id']} {a['name']}", "Start": parse_iso(a["earliest_start"]), "Finish": parse_iso(a["earliest_finish"]), "Kind": a["kind"], } for a in sched ]).sort_values("Start") if not gdf.empty: fig = px.timeline( gdf, x_start="Start", x_end="Finish", y="Activity", color="Kind", color_discrete_map=KIND_COLORS, ) fig.update_yaxes(autorange="reversed") fig.update_layout(height=80 + 36 * len(gdf), legend_title="Kind", margin=dict(l=10, r=10, t=10, b=10)) st.plotly_chart(fig, use_container_width=True) # DAG st.subheader("🕸️ Dependency DAG") st.caption("Node colour = activity kind. Edge label = dep_type / lag.") deps = load_dependencies() dot = ["digraph G {", 'rankdir=LR; node [style=filled, fontcolor=white, shape=box, fontsize=10];'] for a in sched: color = KIND_COLORS.get(a["kind"], "#64748b") label = f"#{a['activity_id']}\\n{a['name']}\\n{a['kind']}" dot.append(f' {a["activity_id"]} [label="{label}", fillcolor="{color}"];') for d in deps: lag = f" +{d['lag_days']:g}d" if d["lag_days"] else "" style = "dashed" if d["origin"] == "MANUAL" else "solid" dot.append(f' {d["predecessor_id"]} -> {d["successor_id"]} ' f'[label="{d["dep_type"]}{lag}", style={style}, fontsize=9];') dot.append("}") st.graphviz_chart("\n".join(dot)) # Scheduled table st.subheader("🗓️ Scheduled activities") tdf = pd.DataFrame([ { "id": a["activity_id"], "name": a["name"], "kind": a["kind"], "earliest_start": a["earliest_start"], "earliest_finish": a["earliest_finish"], "predecessors": ", ".join(map(str, a["predecessors"])) or "—", "successors": ", ".join(map(str, a["successors"])) or "—", } for a in sched ]) st.dataframe(tdf, use_container_width=True, hide_index=True) # --------------------------------------------------------------------------- ④ Raw tab with tab_raw: st.caption("Inspect the live API responses for the active project.") st.markdown("**GET** `/v1/projects/{id}/activities`") st.json(load_activities(), expanded=False) st.markdown("**GET** `/v1/projects/{id}/dependencies`") st.json(load_dependencies(), expanded=False) st.markdown("**GET** `/v1/projects/{id}/boq-items`") br = api_get(f"/v1/projects/{project_id}/boq-items") st.json(br.json() if ok(br) else {}, expanded=False) st.markdown("**GET** `/v1/projects/{id}/schedule-versions`") sr = api_get(f"/v1/projects/{project_id}/schedule-versions") st.json(sr.json() if ok(sr) else {}, expanded=False) # --------------------------------------------------------------------------- ④ Day-0 Explainability with tab_explain: st.subheader("Day-0 Explainability — how each date is computed") st.caption( "A dry-run of the CPM forward pass (no baseline is written). It shows, " "for every activity, exactly how its earliest start/finish were derived — " "which predecessor drove the start, the clock & calendar used, how the " "duration was computed — plus the critical path (longest chain to finish)." ) exp_start = st.date_input("Day-0 start date", dt.date(2026, 7, 1), key="exp_start") r = api_get(f"/v1/projects/{project_id}/schedule/explain?start_date={exp_start.isoformat()}") if r is None or not ok(r): st.info("Add BOQ items first (tab ①), then return here.") else: data = r.json() acts = data["activities"] if not acts: st.info("No activities yet — add BOQ items in tab ①.") else: by_id = {a["activity_id"]: a for a in acts} crit = set(data["critical_path"]) m1, m2, m3 = st.columns(3) m1.metric("Activities", len(acts)) m2.metric("Project finish", parse_iso(data["project_finish"]).strftime("%Y-%m-%d %H:%M")) m3.metric("Critical path", " → ".join(f"#{i}" for i in data["critical_path"]) or "—") # ① Rules Engine st.markdown("#### ① Rules Engine — BOQ → activities") st.caption("Each activity was generated from its BOQ item's category + procurement_type via a template (no hardcoded if/else).") st.dataframe(pd.DataFrame([{ "activity": f"#{a['activity_id']} {a['name']}", "kind": a["kind"], "clock": a["clock"], "effort_days": a["duration"].get("effort_days"), "lead_time_days": a["duration"].get("lead_time_days"), "calendar": a["calendar_id"] or "24/7", } for a in acts]), hide_index=True, use_container_width=True) # ② DAG + topo order st.markdown("#### ② DAG & topological order") st.caption("Activities are scheduled in this dependency-respecting order (ties broken by id). Critical-path nodes/edges are red.") st.code(" → ".join(f"#{i}" for i in data["topo_order"]) or "(none)", language=None) deps = load_dependencies() crit_edges = set(zip(data["critical_path"], data["critical_path"][1:])) dot = ["digraph G {", 'rankdir=LR; node [style=filled, fontcolor=white, shape=box, fontsize=10];'] for a in acts: color = "#dc2626" if a["activity_id"] in crit else KIND_COLORS.get(a["kind"], "#64748b") dot.append(f' {a["activity_id"]} [label="#{a["activity_id"]}\\n{a["name"]}\\n{a["kind"]}", fillcolor="{color}"];') for d in deps: p, s = d["predecessor_id"], d["successor_id"] is_c = (p, s) in crit_edges lag = f" +{d['lag_days']:g}d" if d["lag_days"] else "" dot.append(f' {p} -> {s} [label="{d["dep_type"]}{lag}", ' f'color="{"#dc2626" if is_c else "#94a3b8"}", penwidth={"3" if is_c else "1"}];') dot.append("}") st.graphviz_chart("\n".join(dot)) # ③ CPM forward-pass derivation st.markdown("#### ③ CPM forward pass — per-activity derivation") st.caption("ES = max over predecessors of (constraint date + lag); EF = ES + duration.") for aid in data["topo_order"]: a = by_id[aid] star = " ⭐" if aid in crit else "" es_lbl = parse_iso(a["earliest_start"]).strftime("%b %d %H:%M") ef_lbl = parse_iso(a["earliest_finish"]).strftime("%b %d %H:%M") with st.expander(f"#{aid} · {a['name']} [{a['kind']}]{star} {es_lbl} → {ef_lbl}"): if a["es_driver"] == "project_start": st.markdown(f"- **Earliest start** = project start `{a['raw_es']}` (no predecessors).") else: st.markdown(f"- **Earliest start** driven by predecessor **#{a['es_driver']}** (the max constraint):") st.dataframe(pd.DataFrame([{ "predecessor": f"#{p['predecessor_id']}", "dep_type": p["dep_type"], "lag_days": p["lag_days"], "pred_finish": p["predecessor_finish"], "constraint_date": p["constraint_date"], } for p in a["predecessors"]]), hide_index=True, use_container_width=True) if a["clock"] == "WORKING": dur = a["duration"] st.markdown( f"- **Clock = WORKING** (calendar `{a['calendar_id'] or '24/7'}`). " f"Duration = {dur['effort_days']} effort-days × {dur['hours_per_day']} h/day " f"= **{dur['working_minutes'] / 60:.1f} working hours**, placed inside open windows." ) if a["snapped_to_working"]: st.markdown(f" - Start snapped forward into the next working window → `{a['earliest_start']}`.") else: st.markdown( f"- **Clock = WALL**. Duration = **{a['duration']['lead_time_days']} calendar days** " "(weekends/holidays ignored — vendor lead time runs 24/7)." ) st.markdown(f"- **Earliest finish = `{a['earliest_finish']}`**.") if a["pins"]: warn = " — ⚠️ violates a dependency" if a["pin_violates_dependency"] else "" st.markdown(f"- **Pins applied:** `{a['pins']}`{warn}") # ④ Gantt with critical path st.markdown("#### ④ Gantt (critical path in red)") gdf = pd.DataFrame([{ "Activity": f"#{a['activity_id']} {a['name']}", "Start": parse_iso(a["earliest_start"]), "Finish": parse_iso(a["earliest_finish"]), "Lane": "Critical" if a["activity_id"] in crit else a["kind"], } for a in acts]).sort_values("Start") cmap = dict(KIND_COLORS) cmap["Critical"] = "#dc2626" fig = px.timeline(gdf, x_start="Start", x_end="Finish", y="Activity", color="Lane", color_discrete_map=cmap) fig.update_yaxes(autorange="reversed") fig.update_layout(height=80 + 34 * len(gdf), margin=dict(l=10, r=10, t=10, b=10)) st.plotly_chart(fig, use_container_width=True)