reno-scheduling-engine / streamlit_app.py
suvradeepp's picture
Deploy reno scheduling engine (FastAPI + Streamlit)
5729a71 verified
Raw
History Blame Contribute Delete
25.9 kB
"""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)