diff --git a/submissions/Dia-Vats/level6/.env.example b/submissions/Dia-Vats/level6/.env.example new file mode 100644 index 000000000..a4b5a6c92 --- /dev/null +++ b/submissions/Dia-Vats/level6/.env.example @@ -0,0 +1,3 @@ +NEO4J_URI=bolt://localhost:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=your_password_here \ No newline at end of file diff --git a/submissions/Dia-Vats/level6/.streamlit/secrets.toml.example b/submissions/Dia-Vats/level6/.streamlit/secrets.toml.example new file mode 100644 index 000000000..35356b531 --- /dev/null +++ b/submissions/Dia-Vats/level6/.streamlit/secrets.toml.example @@ -0,0 +1,4 @@ +[NEO4J] +NEO4J_URI = "bolt://localhost:7687" +NEO4J_USER = "neo4j" +NEO4J_PASSWORD = "your_password_here" \ No newline at end of file diff --git a/submissions/Dia-Vats/level6/DASHBOARD_URL.txt b/submissions/Dia-Vats/level6/DASHBOARD_URL.txt new file mode 100644 index 000000000..76db7f4be --- /dev/null +++ b/submissions/Dia-Vats/level6/DASHBOARD_URL.txt @@ -0,0 +1 @@ +https://diavats-l6.streamlit.app \ No newline at end of file diff --git a/submissions/Dia-Vats/level6/README.md b/submissions/Dia-Vats/level6/README.md new file mode 100644 index 000000000..032da445c --- /dev/null +++ b/submissions/Dia-Vats/level6/README.md @@ -0,0 +1,108 @@ +# Steel Factory Production Dashboard +**Dia Vats | Level 6 - LifeAtlas LPI Developer Kit** +**Live: https://diavats-l6.streamlit.app** + +--- + +## What I Built + +The raw data was 3 CSVs describing a Swedish steel factory - 8 projects, 9 stations, 14 workers, 8 weeks. I turned it into a Neo4j knowledge graph and built a 7-page Streamlit dashboard on top of it. + +The point wasn't to build a chart on top of a spreadsheet. The graph models actual operational dependencies - which work orders flow through which stations, which workers can cover which stations if someone's out, and where downstream risk accumulates when a station overruns. The dashboard surfaces that reasoning directly. + +I also completed 2 bonus pages (Bonus B — Factory Floor, Bonus C — Forecast). + +**Graph stats: 148 nodes, 446 relationships, 9 labels, 12 relationship types.** + +--- + +## Graph Schema + +**Nodes:** Project, WorkOrder, Station, Product, Week, Worker, Certification, CapacitySnapshot, Etapp + +**Relationships:** HAS_WORKORDER, AT_STATION, PRODUCES, SCHEDULED_IN, HAS_CAPACITY, ASSIGNED_TO, CAN_COVER, CERTIFIED_IN, REQUIRES, FEEDS_INTO, FOLLOWS, IN_ETAPP + +A few things worth noting: + +- WorkOrder is the core unit - one node per row in the production CSV. It sits between Project and Station and carries planned hours, actual hours, variance %, and bottleneck flag. +- FEEDS_INTO chains stations in physical flow order (011 → 012 → ... → 021). This lets you trace downstream impact from any overrunning station. +- FOLLOWS links the same project-station pair across consecutive weeks so you can traverse time without aggregating in every query. +- Bottleneck rule: `actual_hours > planned_hours × 1.1` +- WorkOrder ID format: `P01_011_w1_IQB` + +--- + +## Dashboard Pages + +**Page 1 - Project Overview** +KPI cards, grouped bar chart (planned vs actual per project), variance table with red highlights where variance exceeds 10%. + +**Page 2 - Station Load** +Plotly heatmap, stations vs weeks, coloured green/yellow/red by variance. Station 016 shows up clearly as the recurring problem. + +**Page 3 - Capacity Tracker** +Weekly capacity vs planned demand. Deficit weeks in red. Calls out that 5 of 8 weeks run over capacity, worst at -132 hours in week 1. + +**Page 4 - Worker Coverage** +Shows who covers which station and flags single points of failure. Station 016 is marked CRITICAL — Victor Elm is the only backup for Per Hansen, and 4 projects run through that station. + +**Page 5 - Factory Floor (Bonus B)** +Scatter-based floor plan with stations on a physical grid, coloured by load severity. Hover shows active projects and overload %. + +**Page 6 - Forecast (Bonus C)** +Linear extrapolation from weeks 1–8 per station, projecting week 9. Shows which stations are trending toward overload. + +**Page 7 - Self-Test** +6 automated Neo4j checks, scored out of 20. Runs on every page load. + +--- + +## Project Structure + +``` +submissions/Dia-Vats/level6/ +├── app.py +├── db.py +├── seed_graph.py +├── requirements.txt +├── .env.example +├── DASHBOARD_URL.txt +├── README.md +├── data/ +│ ├── factory_production.csv +│ ├── factory_workers.csv +│ └── factory_capacity.csv +├── pages_impl/ +│ ├── page1_overview.py +│ ├── page2_station.py +│ ├── page3_capacity.py +│ ├── page4_workers.py +│ ├── page5_floor.py +│ ├── page6_forecast.py +│ └── page7_selftest.py +└── .streamlit/ + └── secrets.toml.example +``` + +--- + +## Running It + +```bash +pip install -r requirements.txt +python seed_graph.py +streamlit run app.py +``` + +For Streamlit Cloud, add credentials under Settings > Secrets: +```toml +NEO4J_URI = "neo4j+s://your-instance.databases.neo4j.io" +NEO4J_USER = "your-username" +NEO4J_PASSWORD = "your-password" +``` + +`seed_graph.py` uses MERGE everywhere — safe to re-run without duplicating data. + +--- + +*Made by Dia Vats* \ No newline at end of file diff --git a/submissions/Dia-Vats/level6/app.py b/submissions/Dia-Vats/level6/app.py new file mode 100644 index 000000000..be703c038 --- /dev/null +++ b/submissions/Dia-Vats/level6/app.py @@ -0,0 +1,105 @@ +""" +app.py — Swedish Steel Factory Dashboard +Author: Dia Vats +7-page Streamlit + Neo4j dashboard. +""" +import streamlit as st + +st.set_page_config( + page_title="Steel Factory Dashboard", + page_icon="[SF]", + layout="wide", + initial_sidebar_state="expanded", +) + +# ── Global styles ───────────────────────────────────────────────────────────── +st.markdown(""" + +""", unsafe_allow_html=True) + +PAGES = [ + "Project Overview", + "Station Load", + "Capacity Tracker", + "Worker Coverage", + "Factory Floor", + "Forecast", + "Self-Test", +] + +# ── Sidebar navigation ──────────────────────────────────────────────────────── +with st.sidebar: + st.image("https://img.icons8.com/fluency/96/factory.png", width=56) + st.markdown("## Steel Factory") + st.markdown("*Neo4j Production Dashboard*") + st.markdown("---") + page = st.radio("Navigate", PAGES, label_visibility="collapsed") + st.markdown("---") + st.caption("Made by **Dia Vats**") + +# ── Footer helper ───────────────────────────────────────────────────────────── +def footer(): + st.caption("Made by Dia Vats") + +# ── Route to page ───────────────────────────────────────────────────────────── +if page == PAGES[0]: + from pages_impl.page1_overview import render; render(); footer() +elif page == PAGES[1]: + from pages_impl.page2_station import render; render(); footer() +elif page == PAGES[2]: + from pages_impl.page3_capacity import render; render(); footer() +elif page == PAGES[3]: + from pages_impl.page4_workers import render; render(); footer() +elif page == PAGES[4]: + from pages_impl.page5_floor import render; render(); footer() +elif page == PAGES[5]: + from pages_impl.page6_forecast import render; render(); footer() +elif page == PAGES[6]: + from pages_impl.page7_selftest import render; render(); footer() diff --git a/submissions/Dia-Vats/level6/data/factory_capacity.csv b/submissions/Dia-Vats/level6/data/factory_capacity.csv new file mode 100644 index 000000000..795ff52f0 --- /dev/null +++ b/submissions/Dia-Vats/level6/data/factory_capacity.csv @@ -0,0 +1,9 @@ +week,own_staff_count,hired_staff_count,own_hours,hired_hours,overtime_hours,total_capacity,total_planned,deficit +w1,10,2,400,80,0,480,612,-132 +w2,10,2,400,80,40,520,645,-125 +w3,10,2,400,80,0,480,398,82 +w4,10,2,400,80,20,500,550,-50 +w5,10,2,400,80,30,510,480,30 +w6,9,2,360,80,0,440,520,-80 +w7,10,2,400,80,40,520,600,-80 +w8,10,2,400,80,20,500,470,30 \ No newline at end of file diff --git a/submissions/Dia-Vats/level6/data/factory_production.csv b/submissions/Dia-Vats/level6/data/factory_production.csv new file mode 100644 index 000000000..ca6ce43e1 --- /dev/null +++ b/submissions/Dia-Vats/level6/data/factory_production.csv @@ -0,0 +1,69 @@ +project_id,project_number,project_name,product_type,unit,quantity,unit_factor,station_code,station_name,etapp,bop,week,planned_hours,actual_hours,completed_units +P01,4501,Stålverket Borås,IQB,meter,600,1.77,011,FS IQB,ET1,BOP1,w1,48.0,45.2,28 +P01,4501,Stålverket Borås,IQB,meter,600,1.77,012,Förmontering IQB,ET1,BOP1,w1,32.0,35.5,25 +P01,4501,Stålverket Borås,IQB,meter,600,1.77,013,Montering IQB,ET1,BOP1,w1,28.0,26.0,22 +P01,4501,Stålverket Borås,IQB,meter,600,1.77,014,Svets o montage IQB,ET1,BOP1,w1,35.0,38.2,20 +P01,4501,Stålverket Borås,SB,styck,40,4.0,018,SB B/F-hall,ET1,BOP1,w1,16.0,14.5,4 +P01,4501,Stålverket Borås,SP,styck,180,2.0,019,SP B/F-hall,ET1,BOP1,w1,12.0,13.0,7 +P01,4501,Stålverket Borås,IQB,meter,600,1.77,011,FS IQB,ET1,BOP1,w2,48.0,50.0,32 +P01,4501,Stålverket Borås,IQB,meter,600,1.77,012,Förmontering IQB,ET1,BOP1,w2,32.0,30.0,28 +P01,4501,Stålverket Borås,IQP,styck,90,2.80,015,Montering IQP,ET1,BOP2,w2,25.0,28.0,9 +P01,4501,Stålverket Borås,SR,styck,8,45.0,021,SR B/F-hall,ET1,BOP2,w2,40.0,42.0,1 +P02,4502,Kontorshus Mölndal,IQB,meter,350,1.50,011,FS IQB,ET1,BOP1,w1,30.0,28.0,20 +P02,4502,Kontorshus Mölndal,IQB,meter,350,1.50,012,Förmontering IQB,ET1,BOP1,w1,22.0,24.5,18 +P02,4502,Kontorshus Mölndal,IQB,meter,350,1.50,013,Montering IQB,ET1,BOP1,w1,18.0,17.0,16 +P02,4502,Kontorshus Mölndal,IQP,styck,70,2.70,015,Montering IQP,ET1,BOP1,w1,19.0,21.0,7 +P02,4502,Kontorshus Mölndal,SD,styck,30,3.00,018,SB B/F-hall,ET1,BOP1,w1,9.0,8.5,3 +P02,4502,Kontorshus Mölndal,IQB,meter,350,1.50,011,FS IQB,ET1,BOP1,w2,30.0,32.0,24 +P02,4502,Kontorshus Mölndal,IQB,meter,350,1.50,014,Svets o montage IQB,ET1,BOP1,w2,25.0,23.0,20 +P02,4502,Kontorshus Mölndal,SP,styck,120,1.75,019,SP B/F-hall,ET1,BOP2,w2,14.0,15.5,8 +P03,4503,Lagerhall Jönköping,IQB,meter,900,1.89,011,FS IQB,ET1,BOP1,w1,72.0,70.0,40 +P03,4503,Lagerhall Jönköping,IQB,meter,900,1.89,012,Förmontering IQB,ET1,BOP1,w1,48.0,52.0,35 +P03,4503,Lagerhall Jönköping,IQB,meter,900,1.89,013,Montering IQB,ET1,BOP1,w1,38.0,36.5,30 +P03,4503,Lagerhall Jönköping,IQB,meter,900,1.89,014,Svets o montage IQB,ET1,BOP1,w1,42.0,48.0,28 +P03,4503,Lagerhall Jönköping,SB,styck,60,6.00,018,SB B/F-hall,ET1,BOP1,w1,36.0,38.0,6 +P03,4503,Lagerhall Jönköping,IQB,meter,900,1.89,011,FS IQB,ET1,BOP1,w2,72.0,75.0,45 +P03,4503,Lagerhall Jönköping,IQP,styck,110,2.90,015,Montering IQP,ET1,BOP2,w2,32.0,30.0,11 +P03,4503,Lagerhall Jönköping,IQB,meter,900,1.89,016,Gjutning,ET1,BOP2,w2,28.0,35.0,8 +P03,4503,Lagerhall Jönköping,IQB,meter,900,1.89,017,Målning,ET1,BOP2,w3,24.0,22.0,20 +P04,4504,Parkering Helsingborg,IQB,meter,450,1.65,011,FS IQB,ET1,BOP1,w1,38.0,36.0,24 +P04,4504,Parkering Helsingborg,IQB,meter,450,1.65,012,Förmontering IQB,ET1,BOP1,w1,25.0,27.0,20 +P04,4504,Parkering Helsingborg,IQB,meter,450,1.65,013,Montering IQB,ET1,BOP1,w1,20.0,19.0,18 +P04,4504,Parkering Helsingborg,IQP,styck,55,2.85,015,Montering IQP,ET1,BOP1,w1,16.0,18.0,6 +P04,4504,Parkering Helsingborg,SB,styck,25,7.50,018,SB B/F-hall,ET1,BOP1,w1,19.0,22.0,3 +P04,4504,Parkering Helsingborg,IQB,meter,450,1.65,011,FS IQB,ET1,BOP1,w2,38.0,40.0,28 +P04,4504,Parkering Helsingborg,SP,styck,100,2.00,019,SP B/F-hall,ET1,BOP2,w2,12.0,11.0,6 +P04,4504,Parkering Helsingborg,SR,styck,12,120.0,021,SR B/F-hall,ET1,BOP2,w2,60.0,65.0,1 +P05,4505,Sjukhus Linköping ET2,IQB,meter,1200,1.85,011,FS IQB,ET2,BOP3,w1,95.0,90.0,50 +P05,4505,Sjukhus Linköping ET2,IQB,meter,1200,1.85,012,Förmontering IQB,ET2,BOP3,w1,65.0,68.0,42 +P05,4505,Sjukhus Linköping ET2,IQB,meter,1200,1.85,013,Montering IQB,ET2,BOP3,w1,50.0,48.0,38 +P05,4505,Sjukhus Linköping ET2,IQB,meter,1200,1.85,014,Svets o montage IQB,ET2,BOP3,w1,58.0,62.0,35 +P05,4505,Sjukhus Linköping ET2,IQP,styck,150,2.88,015,Montering IQP,ET2,BOP3,w1,30.0,33.0,10 +P05,4505,Sjukhus Linköping ET2,SB,styck,50,5.00,018,SB B/F-hall,ET2,BOP3,w1,25.0,28.0,5 +P05,4505,Sjukhus Linköping ET2,SD,styck,45,2.75,018,SB B/F-hall,ET2,BOP3,w1,12.0,11.5,4 +P05,4505,Sjukhus Linköping ET2,IQB,meter,1200,1.85,011,FS IQB,ET2,BOP3,w2,95.0,98.0,55 +P05,4505,Sjukhus Linköping ET2,IQB,meter,1200,1.85,016,Gjutning,ET2,BOP3,w2,35.0,40.0,12 +P05,4505,Sjukhus Linköping ET2,IQB,meter,1200,1.85,017,Målning,ET2,BOP3,w2,28.0,26.0,25 +P05,4505,Sjukhus Linköping ET2,SR,styck,20,274.0,021,SR B/F-hall,ET2,BOP3,w3,120.0,115.0,2 +P06,4506,Skola Uppsala,IQB,meter,500,1.60,011,FS IQB,ET1,BOP1,w2,40.0,38.0,26 +P06,4506,Skola Uppsala,IQB,meter,500,1.60,012,Förmontering IQB,ET1,BOP1,w2,28.0,30.0,22 +P06,4506,Skola Uppsala,IQB,meter,500,1.60,013,Montering IQB,ET1,BOP1,w2,22.0,20.0,18 +P06,4506,Skola Uppsala,IQP,styck,80,2.75,015,Montering IQP,ET1,BOP1,w2,22.0,24.0,8 +P06,4506,Skola Uppsala,SB,styck,35,4.50,018,SB B/F-hall,ET1,BOP1,w2,16.0,18.0,4 +P06,4506,Skola Uppsala,SP,styck,140,1.50,019,SP B/F-hall,ET1,BOP2,w3,14.0,12.0,10 +P07,4507,Idrottshall Västerås,HSQ,meter,400,2.05,011,FS IQB,ET1,BOP1,w1,45.0,42.0,22 +P07,4507,Idrottshall Västerås,HSQ,meter,400,2.05,012,Förmontering IQB,ET1,BOP1,w1,30.0,33.0,18 +P07,4507,Idrottshall Västerås,HSQ,meter,400,2.05,014,Svets o montage IQB,ET1,BOP1,w1,35.0,32.0,16 +P07,4507,Idrottshall Västerås,SB,styck,45,3.50,018,SB B/F-hall,ET1,BOP1,w1,16.0,18.0,5 +P07,4507,Idrottshall Västerås,HSQ,meter,400,2.05,011,FS IQB,ET1,BOP1,w2,45.0,48.0,26 +P07,4507,Idrottshall Västerås,HSQ,meter,400,2.05,016,Gjutning,ET1,BOP2,w2,20.0,22.0,5 +P07,4507,Idrottshall Västerås,HSQ,meter,400,2.05,017,Målning,ET1,BOP2,w3,18.0,16.0,15 +P08,4508,Bro E6 Halmstad,IQB,meter,800,1.80,011,FS IQB,ET1,BOP1,w1,65.0,62.0,36 +P08,4508,Bro E6 Halmstad,IQB,meter,800,1.80,012,Förmontering IQB,ET1,BOP1,w1,42.0,45.0,30 +P08,4508,Bro E6 Halmstad,IQB,meter,800,1.80,013,Montering IQB,ET1,BOP1,w1,35.0,38.0,25 +P08,4508,Bro E6 Halmstad,IQB,meter,800,1.80,014,Svets o montage IQB,ET1,BOP1,w1,40.0,44.0,22 +P08,4508,Bro E6 Halmstad,SP,styck,200,2.50,019,SP B/F-hall,ET1,BOP1,w1,20.0,18.0,8 +P08,4508,Bro E6 Halmstad,IQB,meter,800,1.80,011,FS IQB,ET1,BOP1,w2,65.0,68.0,42 +P08,4508,Bro E6 Halmstad,IQP,styck,95,2.93,015,Montering IQP,ET1,BOP2,w2,28.0,30.0,10 +P08,4508,Bro E6 Halmstad,IQB,meter,800,1.80,016,Gjutning,ET1,BOP2,w3,22.0,25.0,8 +P08,4508,Bro E6 Halmstad,SR,styck,15,180.0,021,SR B/F-hall,ET1,BOP2,w3,90.0,85.0,2 \ No newline at end of file diff --git a/submissions/Dia-Vats/level6/data/factory_workers.csv b/submissions/Dia-Vats/level6/data/factory_workers.csv new file mode 100644 index 000000000..3110285cc --- /dev/null +++ b/submissions/Dia-Vats/level6/data/factory_workers.csv @@ -0,0 +1,15 @@ +worker_id,name,role,primary_station,can_cover_stations,certifications,hours_per_week,type +W01,Erik Lindberg,Operator,011,"011,012","MIG/MAG,TIG,ISO 9606",40,permanent +W02,Anna Berg,Operator,011,"011,014","MIG/MAG,TIG",40,permanent +W03,Lars Jensen,Operator,012,"012,013","Surface treatment,CE marking",40,permanent +W04,Maria Stone,Operator,013,"013","Blasting,Surface protection",40,permanent +W05,Johan Peters,Operator,014,"014,015","Hydraulics,Mechanics,Crane",40,permanent +W06,Karen Nilsen,Inspector,015,"015","SIS,SS-EN 1090,NDT",40,permanent +W07,Per Hansen,Operator,016,"016,017","Casting,Formwork",40,permanent +W08,Sofia Arden,Operator,017,"017","Surface treatment,Spray painting",40,permanent +W09,Magnus Stone,Operator,018,"018,019","Sheet metal,Assembly",40,permanent +W10,Elin Frank,Operator,019,"019,018","Assembly,Welding",32,permanent +W11,Victor Elm,Foreman,all,"011,012,013,014,015,016,017,018,019,021","Leadership,CE,ISO 9001",45,permanent +W12,Lena Dale,Quality Manager,015,"015","ISO 9001,SS-EN 1090,Audit",40,permanent +W13,Ahmed Hassan,Operator,011,"011","MIG/MAG",40,hired +W14,Petra Steen,Operator,012,"012,013","Surface treatment",40,hired \ No newline at end of file diff --git a/submissions/Dia-Vats/level6/db.py b/submissions/Dia-Vats/level6/db.py new file mode 100644 index 000000000..a9d4782c6 --- /dev/null +++ b/submissions/Dia-Vats/level6/db.py @@ -0,0 +1,25 @@ +"""db.py — shared Neo4j driver for the Streamlit dashboard.""" +import os +import streamlit as st +from neo4j import GraphDatabase + +@st.cache_resource(show_spinner=False) +def get_driver(): + try: + uri = st.secrets["NEO4J_URI"] + user = st.secrets["NEO4J_USER"] + pw = st.secrets["NEO4J_PASSWORD"] + except Exception: + from dotenv import load_dotenv + load_dotenv() + uri = os.getenv("NEO4J_URI", "bolt://localhost:7687") + user = os.getenv("NEO4J_USER", "neo4j") + pw = os.getenv("NEO4J_PASSWORD", "password") + return GraphDatabase.driver(uri, auth=(user, pw)) + + +def run_query(cypher: str, params: dict | None = None) -> list[dict]: + driver = get_driver() + with driver.session() as s: + result = s.run(cypher, params or {}) + return [dict(r) for r in result] diff --git a/submissions/Dia-Vats/level6/pages_impl/__init__.py b/submissions/Dia-Vats/level6/pages_impl/__init__.py new file mode 100644 index 000000000..63024e556 --- /dev/null +++ b/submissions/Dia-Vats/level6/pages_impl/__init__.py @@ -0,0 +1 @@ +# pages_impl/__init__.py \ No newline at end of file diff --git a/submissions/Dia-Vats/level6/pages_impl/page1_overview.py b/submissions/Dia-Vats/level6/pages_impl/page1_overview.py new file mode 100644 index 000000000..a30b0f008 --- /dev/null +++ b/submissions/Dia-Vats/level6/pages_impl/page1_overview.py @@ -0,0 +1,82 @@ +"""Page 1 — Project Overview""" +import streamlit as st +import pandas as pd +import plotly.graph_objects as go +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from db import run_query + + +def render(): + st.markdown('
Project Overview
', unsafe_allow_html=True) + st.markdown("Planned vs actual hours per project, variance analysis, and bottleneck summary.") + + rows = run_query(""" + MATCH (p:Project)-[:HAS_WORKORDER]->(wo:WorkOrder) + RETURN + p.project_id AS project_id, + p.project_name AS project_name, + sum(wo.planned_hours) AS total_planned, + sum(wo.actual_hours) AS total_actual, + sum(CASE WHEN wo.is_bottleneck THEN 1 ELSE 0 END) AS bottleneck_count + ORDER BY p.project_id + """) + + if not rows: + st.error("No data returned. Run seed_graph.py first.") + return + + df = pd.DataFrame(rows) + df["variance_pct"] = ((df["total_actual"] - df["total_planned"]) / df["total_planned"] * 100).round(1) + + # ── KPI cards ───────────────────────────────────────────────────────────── + col1, col2, col3, col4 = st.columns(4) + with col1: + st.markdown(f'
Total Projects
{len(df)}
', unsafe_allow_html=True) + with col2: + st.markdown(f'
Total Planned hrs
{int(df["total_planned"].sum()):,}
', unsafe_allow_html=True) + with col3: + st.markdown(f'
Total Actual hrs
{int(df["total_actual"].sum()):,}
', unsafe_allow_html=True) + with col4: + btn = int(df["bottleneck_count"].sum()) + st.markdown(f'
Bottleneck Work Orders
{btn}
', unsafe_allow_html=True) + + st.markdown("---") + + # ── Grouped bar chart ────────────────────────────────────────────────────── + fig = go.Figure() + fig.add_trace(go.Bar( + name="Planned hrs", x=df["project_name"], y=df["total_planned"], + marker_color="#3b82f6", marker_line_color="#1d4ed8", marker_line_width=1, + )) + fig.add_trace(go.Bar( + name="Actual hrs", x=df["project_name"], y=df["total_actual"], + marker_color="#f97316", marker_line_color="#c2410c", marker_line_width=1, + )) + fig.update_layout( + barmode="group", template="plotly_dark", + title="Planned vs Actual Hours — All Projects", + xaxis_title="Project", yaxis_title="Hours", + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + height=420, margin=dict(l=40, r=20, t=60, b=100), + xaxis_tickangle=-30, + paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(15,17,23,0.6)", + ) + st.plotly_chart(fig, use_container_width=True) + + st.markdown("---") + st.markdown("**PROJECT DETAIL TABLE**") + + # ── Variance table ───────────────────────────────────────────────────────── + display_df = df[["project_id","project_name","total_planned","total_actual","variance_pct","bottleneck_count"]].copy() + display_df.columns = ["ID","Project","Planned hrs","Actual hrs","Variance %","Bottlenecks"] + + def color_variance(val): + if val > 10: + return "background-color:#7f1d1d; color:#fca5a5; font-weight:600" + elif val > 0: + return "color:#fbbf24" + return "color:#4ade80" + + styled = display_df.style.applymap(color_variance, subset=["Variance %"]) + st.dataframe(styled, use_container_width=True, hide_index=True) diff --git a/submissions/Dia-Vats/level6/pages_impl/page2_station.py b/submissions/Dia-Vats/level6/pages_impl/page2_station.py new file mode 100644 index 000000000..f5034deea --- /dev/null +++ b/submissions/Dia-Vats/level6/pages_impl/page2_station.py @@ -0,0 +1,96 @@ +"""Page 2 — Station Load Heatmap""" +import streamlit as st +import pandas as pd +import plotly.graph_objects as go +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from db import run_query + + +def render(): + st.markdown('
Station Load
', unsafe_allow_html=True) + st.markdown("Heatmap of variance % by station × week. Red = overloaded (>10%), green = on track.") + + rows = run_query(""" + MATCH (wo:WorkOrder)-[:AT_STATION]->(s:Station) + MATCH (wo)-[r:SCHEDULED_IN]->(wk:Week) + RETURN + s.station_code AS station_code, + s.station_name AS station_name, + wk.week_id AS week_id, + sum(r.planned_hours) AS planned, + sum(r.actual_hours) AS actual + ORDER BY s.station_code, wk.week_id + """) + + if not rows: + st.error("No data returned. Run seed_graph.py first.") + return + + df = pd.DataFrame(rows) + df["variance_pct"] = ((df["actual"] - df["planned"]) / df["planned"] * 100).round(1) + df["label"] = df["station_code"] + "\n" + df["station_name"] + + # pivot for heatmap + week_order = ["w1","w2","w3","w4","w5","w6","w7","w8"] + pivot = df.pivot_table(index="label", columns="week_id", values="variance_pct", aggfunc="mean") + pivot = pivot.reindex(columns=[w for w in week_order if w in pivot.columns]) + + stations = list(pivot.index) + weeks = list(pivot.columns) + z_vals = pivot.values.tolist() + + # Custom text for hover + hover_text = [] + for s_label in stations: + row_texts = [] + for wk in weeks: + try: + v = pivot.loc[s_label, wk] + row_texts.append(f"Station: {s_label}
Week: {wk}
Variance: {v:.1f}%") + except Exception: + row_texts.append("") + hover_text.append(row_texts) + + fig = go.Figure(data=go.Heatmap( + z=z_vals, + x=weeks, + y=stations, + text=pivot.values.tolist(), + texttemplate="%{text:.1f}%", + colorscale=[ + [0.0, "#166534"], + [0.3, "#4ade80"], + [0.5, "#facc15"], + [0.7, "#f97316"], + [1.0, "#991b1b"], + ], + zmid=0, + zmin=-20, zmax=30, + colorbar=dict(title="Variance %", ticksuffix="%"), + hovertext=hover_text, + hoverinfo="text", + )) + fig.update_layout( + template="plotly_dark", + title="Station Load Heatmap — Variance % (Actual vs Planned)", + xaxis_title="Week", yaxis_title="Station", + height=520, + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(15,17,23,0.8)", + margin=dict(l=180, r=20, t=60, b=40), + ) + st.plotly_chart(fig, use_container_width=True) + + st.markdown("---") + st.markdown("**OVERLOADED CELLS — VARIANCE > 10%**") + overloaded = df[df["variance_pct"] > 10][["station_code","station_name","week_id","planned","actual","variance_pct"]] + overloaded.columns = ["Code","Station","Week","Planned hrs","Actual hrs","Variance %"] + if len(overloaded): + st.dataframe( + overloaded.style.highlight_between(subset=["Variance %"], left=10, right=999, + props="background-color:#7f1d1d;color:#fca5a5;font-weight:600"), + use_container_width=True, hide_index=True + ) + else: + st.success("No overloaded cells found.") diff --git a/submissions/Dia-Vats/level6/pages_impl/page3_capacity.py b/submissions/Dia-Vats/level6/pages_impl/page3_capacity.py new file mode 100644 index 000000000..968bd2d8d --- /dev/null +++ b/submissions/Dia-Vats/level6/pages_impl/page3_capacity.py @@ -0,0 +1,98 @@ +"""Page 3 — Capacity Tracker""" +import streamlit as st +import pandas as pd +import plotly.graph_objects as go +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from db import run_query + + +def render(): + st.markdown('
Capacity Tracker
', unsafe_allow_html=True) + st.markdown("Weekly factory capacity vs planned demand. Red bars = deficit weeks.") + + rows = run_query(""" + MATCH (wk:Week)-[:HAS_CAPACITY]->(cs:CapacitySnapshot) + RETURN + wk.week_id AS week_id, + cs.total_capacity AS total_capacity, + cs.total_planned AS total_planned, + cs.deficit AS deficit, + cs.overtime_hours AS overtime_hours, + cs.own_staff_count AS own_staff, + cs.hired_staff_count AS hired_staff + ORDER BY wk.week_id + """) + + if not rows: + st.error("No data returned. Run seed_graph.py first.") + return + + df = pd.DataFrame(rows) + week_order = ["w1","w2","w3","w4","w5","w6","w7","w8"] + df["week_id"] = pd.Categorical(df["week_id"], categories=week_order, ordered=True) + df = df.sort_values("week_id") + + deficit_weeks = int((df["deficit"] < 0).sum()) + total_weeks = len(df) + + # ── KPI cards ───────────────────────────────────────────────────────────── + col1, col2, col3 = st.columns(3) + with col1: + st.markdown(f'
Deficit Weeks
{deficit_weeks} of {total_weeks}
', unsafe_allow_html=True) + with col2: + total_def = int(df[df["deficit"] < 0]["deficit"].sum()) + st.markdown(f'
Total Deficit hrs
{total_def}
', unsafe_allow_html=True) + with col3: + ot = int(df["overtime_hours"].sum()) + st.markdown(f'
Total Overtime hrs
{ot}
', unsafe_allow_html=True) + + # Insight text + st.warning(f"**{deficit_weeks} of {total_weeks} weeks are in deficit** — factory demand exceeds available capacity in {deficit_weeks} out of {total_weeks} weeks. Consider scheduling overtime or additional hired staff.") + + st.markdown("---") + + # ── Grouped bar chart ────────────────────────────────────────────────────── + cap_colors = ["#3b82f6"] * len(df) + plan_colors = ["#ef4444" if d < 0 else "#f97316" for d in df["deficit"]] + + fig = go.Figure() + fig.add_trace(go.Bar( + name="Total Capacity", x=df["week_id"], y=df["total_capacity"], + marker_color=cap_colors, opacity=0.85, + )) + fig.add_trace(go.Bar( + name="Total Planned", x=df["week_id"], y=df["total_planned"], + marker_color=plan_colors, opacity=0.9, + )) + fig.add_trace(go.Scatter( + name="Deficit / Surplus", x=df["week_id"], y=df["deficit"], + mode="lines+markers", + line=dict(color="#a855f7", width=2, dash="dot"), + marker=dict(size=8, color=["#ef4444" if d < 0 else "#4ade80" for d in df["deficit"]]), + )) + fig.add_hline(y=0, line_color="#64748b", line_dash="dash") + fig.update_layout( + barmode="group", template="plotly_dark", + title="Weekly Capacity vs Planned Demand", + xaxis_title="Week", yaxis_title="Hours", + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + height=440, + paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(15,17,23,0.6)", + ) + st.plotly_chart(fig, use_container_width=True) + + st.markdown("---") + st.markdown("**WEEKLY BREAKDOWN**") + display = df[["week_id","own_staff","hired_staff","total_capacity","total_planned","overtime_hours","deficit"]].copy() + display.columns = ["Week","Own Staff","Hired Staff","Capacity hrs","Planned hrs","Overtime hrs","Deficit"] + + def color_deficit(val): + if val < 0: + return "background-color:#7f1d1d; color:#fca5a5; font-weight:700" + return "color:#4ade80; font-weight:700" + + st.dataframe( + display.style.applymap(color_deficit, subset=["Deficit"]), + use_container_width=True, hide_index=True + ) diff --git a/submissions/Dia-Vats/level6/pages_impl/page4_workers.py b/submissions/Dia-Vats/level6/pages_impl/page4_workers.py new file mode 100644 index 000000000..e2cf4c20d --- /dev/null +++ b/submissions/Dia-Vats/level6/pages_impl/page4_workers.py @@ -0,0 +1,118 @@ +"""Page 4 — Worker Coverage""" +import streamlit as st +import pandas as pd +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from db import run_query + + +def render(): + st.markdown('
Worker Coverage
', unsafe_allow_html=True) + st.markdown("Station coverage matrix — SPOF (Single Point of Failure) stations highlighted in red.") + + # Coverage query + coverage_rows = run_query(""" + MATCH (s:Station) + OPTIONAL MATCH (w:Worker)-[:CAN_COVER]->(s) + WITH s, + collect(DISTINCT w.name) AS coverers, + count(DISTINCT w) AS coverage_count + OPTIONAL MATCH (wo:WorkOrder)-[:AT_STATION]->(s) + OPTIONAL MATCH (p:Project)-[:HAS_WORKORDER]->(wo) + WITH s, coverers, coverage_count, + collect(DISTINCT p.project_name) AS active_projects + RETURN + s.station_code AS station_code, + s.station_name AS station_name, + coverers, + coverage_count, + active_projects, + CASE WHEN coverage_count <= 1 THEN true ELSE false END AS is_spof + ORDER BY coverage_count ASC + """) + + if not coverage_rows: + st.error("No data returned. Run seed_graph.py first.") + return + + df = pd.DataFrame(coverage_rows) + + # ── Summary metrics ──────────────────────────────────────────────────────── + spof_count = int((df["is_spof"] == True).sum()) + col1, col2, col3 = st.columns(3) + with col1: + st.markdown(f'
Total Stations
{len(df)}
', unsafe_allow_html=True) + with col2: + st.markdown(f'
SPOF Stations
{spof_count}
', unsafe_allow_html=True) + with col3: + avg_cov = round(df["coverage_count"].mean(), 1) + st.markdown(f'
Avg Coverage per Station
{avg_cov}
', unsafe_allow_html=True) + + st.markdown("---") + + # ── Station table ────────────────────────────────────────────────────────── + st.markdown("**STATION COVERAGE MATRIX**") + for _, row in df.iterrows(): + spof_html = 'CRITICAL' if row["is_spof"] else 'OK' + workers_str = ", ".join(row["coverers"]) if row["coverers"] else "—" + projects_str = ", ".join(row["active_projects"][:4]) if row["active_projects"] else "—" + cert_count = len(row["coverers"]) # proxy + + with st.expander(f"**{row['station_code']}** — {row['station_name']} {spof_html}", expanded=row["is_spof"]): + c1, c2, c3 = st.columns([2, 2, 3]) + with c1: + st.metric("Coverage Count", int(row["coverage_count"])) + with c2: + st.metric("Active Projects", len(row["active_projects"])) + with c3: + st.markdown(f"**Workers who can cover:** {workers_str}") + st.markdown(f"**Dependent projects:** {projects_str}") + if row["is_spof"]: + st.error(f"SPOF ALERT: Only {int(row['coverage_count'])} worker(s) cover this station. " + f"Projects at risk: {projects_str}") + + st.markdown("---") + + # ── SPOF downstream risk ─────────────────────────────────────────────────── + st.markdown("**SPOF DOWNSTREAM RISK (VIA FEEDS\_INTO)**") + spof_downstream = run_query(""" + MATCH (s:Station) + WHERE NOT EXISTS { MATCH (w:Worker)-[:CAN_COVER]->(s) } + OR 1 >= size([(w:Worker)-[:CAN_COVER]->(s) | w]) + MATCH path = (s)-[:FEEDS_INTO*1..5]->(ds:Station) + MATCH (p:Project)-[:HAS_WORKORDER]->(wo:WorkOrder)-[:AT_STATION]->(ds) + RETURN + s.station_code AS spof_station, + s.station_name AS spof_name, + ds.station_code AS downstream_station, + ds.station_name AS downstream_name, + collect(DISTINCT p.project_name) AS at_risk_projects + LIMIT 30 + """) + + if spof_downstream: + df_down = pd.DataFrame(spof_downstream) + df_down.columns = ["SPOF Station","SPOF Name","Downstream Station","Downstream Name","At-Risk Projects"] + df_down["At-Risk Projects"] = df_down["At-Risk Projects"].apply(lambda x: ", ".join(x)) + st.dataframe(df_down, use_container_width=True, hide_index=True) + else: + st.info("No downstream risk paths found.") + + st.markdown("---") + st.markdown("**ALL WORKERS**") + workers_rows = run_query(""" + MATCH (w:Worker) + OPTIONAL MATCH (w)-[:CERTIFIED_IN]->(c:Certification) + RETURN + w.worker_id AS worker_id, + w.name AS name, + w.role AS role, + w.type AS type, + w.hours_per_week AS hours_pw, + collect(c.name) AS certifications + ORDER BY w.worker_id + """) + wdf = pd.DataFrame(workers_rows) + wdf["certifications"] = wdf["certifications"].apply(lambda x: ", ".join(x) if x else "—") + wdf.columns = ["ID","Name","Role","Type","hrs/wk","Certifications"] + st.dataframe(wdf, use_container_width=True, hide_index=True) diff --git a/submissions/Dia-Vats/level6/pages_impl/page5_floor.py b/submissions/Dia-Vats/level6/pages_impl/page5_floor.py new file mode 100644 index 000000000..219831b7b --- /dev/null +++ b/submissions/Dia-Vats/level6/pages_impl/page5_floor.py @@ -0,0 +1,133 @@ +"""Page 5 — Factory Floor (Bonus B)""" +import streamlit as st +import pandas as pd +import plotly.graph_objects as go +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from db import run_query + +# Grid positions per spec: 011→row0,col0 / 012→row0,col1 / ... / 021→row2,col1 +STATION_GRID = { + "011": (0, 0), "012": (0, 1), "013": (0, 2), "014": (0, 3), + "015": (1, 0), "016": (1, 1), "017": (1, 2), "018": (1, 3), + "019": (2, 0), "021": (2, 1), +} + +FEEDS_INTO_EDGES = [ + ("011","012"),("012","013"),("013","014"),("014","015"), + ("015","016"),("016","017"),("017","018"),("018","019"),("019","021"), +] + + +def render(): + st.markdown('
Factory Floor
', unsafe_allow_html=True) + st.markdown("Scatter-based factory floor plan. Stations coloured by load severity. Hover for active projects and overload %.") + + rows = run_query(""" + MATCH (wo:WorkOrder)-[:AT_STATION]->(s:Station) + MATCH (wo)-[r:SCHEDULED_IN]->(wk:Week) + MATCH (p:Project)-[:HAS_WORKORDER]->(wo) + WITH s, + sum(r.planned_hours) AS planned, + sum(r.actual_hours) AS actual, + collect(DISTINCT p.project_name) AS projects + RETURN + s.station_code AS station_code, + s.station_name AS station_name, + planned, actual, projects + """) + + if not rows: + st.error("No data returned. Run seed_graph.py first.") + return + + df = pd.DataFrame(rows) + df["variance_pct"] = ((df["actual"] - df["planned"]) / df["planned"] * 100).round(1) + + def severity_color(v): + if v > 15: return "#ef4444" + if v > 5: return "#f97316" + if v > 0: return "#facc15" + return "#4ade80" + + # Build scatter points + x_vals, y_vals, colors, sizes, labels, hovers = [], [], [], [], [], [] + station_pos = {} # code -> (x, y) for drawing edges + + for _, row in df.iterrows(): + sc = row["station_code"] + if sc not in STATION_GRID: + continue + grid_r, grid_c = STATION_GRID[sc] + # invert row so row0 is at top + x = grid_c * 2.5 + y = (2 - grid_r) * 2.0 + station_pos[sc] = (x, y) + + proj_list = ", ".join(row["projects"][:3]) + hover = (f"{sc} — {row['station_name']}
" + f"Planned: {row['planned']:.0f} hrs
" + f"Actual: {row['actual']:.0f} hrs
" + f"Overload: {row['variance_pct']:.1f}%
" + f"Projects: {proj_list}") + + x_vals.append(x); y_vals.append(y) + colors.append(severity_color(row["variance_pct"])) + sizes.append(40 + max(0, row["variance_pct"]) * 1.5) + labels.append(f"{sc}") + hovers.append(hover) + + fig = go.Figure() + + # Draw FEEDS_INTO edges first + for src, dst in FEEDS_INTO_EDGES: + if src in station_pos and dst in station_pos: + sx, sy = station_pos[src] + dx, dy = station_pos[dst] + fig.add_trace(go.Scatter( + x=[sx, dx], y=[sy, dy], mode="lines", + line=dict(color="#334155", width=2, dash="dot"), + showlegend=False, hoverinfo="skip", + )) + + # Station nodes + fig.add_trace(go.Scatter( + x=x_vals, y=y_vals, mode="markers+text", + marker=dict(color=colors, size=sizes, line=dict(color="#1e293b", width=2), + opacity=0.9), + text=labels, textposition="middle center", + textfont=dict(color="#f1f5f9", size=11, family="Inter"), + hovertext=hovers, hoverinfo="text", + showlegend=False, + )) + + # Legend annotation + for color, label in [("#4ade80","On track"),("#facc15","Slight over"), + ("#f97316",">5% over"),("#ef4444",">15% over")]: + fig.add_trace(go.Scatter( + x=[None], y=[None], mode="markers", + marker=dict(color=color, size=12), + name=label, showlegend=True, + )) + + fig.update_layout( + template="plotly_dark", + title="Factory Floor — Station Load Map", + xaxis=dict(showgrid=False, zeroline=False, showticklabels=False, range=[-0.5, 9]), + yaxis=dict(showgrid=False, zeroline=False, showticklabels=False, range=[-0.5, 5]), + height=500, + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(15,17,23,0.8)", + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + margin=dict(l=20, r=20, t=60, b=20), + ) + st.plotly_chart(fig, use_container_width=True) + + st.markdown("---") + # Table summary + display = df[df["station_code"].isin(STATION_GRID)][ + ["station_code","station_name","planned","actual","variance_pct"] + ].copy() + display.columns = ["Code","Station","Planned hrs","Actual hrs","Overload %"] + st.dataframe(display.style.background_gradient(subset=["Overload %"], cmap="RdYlGn_r"), + use_container_width=True, hide_index=True) diff --git a/submissions/Dia-Vats/level6/pages_impl/page6_forecast.py b/submissions/Dia-Vats/level6/pages_impl/page6_forecast.py new file mode 100644 index 000000000..d98abd2bb --- /dev/null +++ b/submissions/Dia-Vats/level6/pages_impl/page6_forecast.py @@ -0,0 +1,130 @@ +"""Page 6 — Forecast (Bonus C)""" +import streamlit as st +import pandas as pd +import numpy as np +import plotly.graph_objects as go +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from db import run_query + +WEEK_NUM = {"w1":1,"w2":2,"w3":3,"w4":4,"w5":5,"w6":6,"w7":7,"w8":8} + + +def render(): + st.markdown('
Forecast
', unsafe_allow_html=True) + st.markdown("Linear trend extrapolation per station from weeks w1–w8, predicting week 9 load.") + + rows = run_query(""" + MATCH (wo:WorkOrder)-[:AT_STATION]->(s:Station) + MATCH (wo)-[r:SCHEDULED_IN]->(wk:Week) + RETURN + s.station_code AS station_code, + s.station_name AS station_name, + wk.week_id AS week_id, + sum(r.actual_hours) AS actual_hours + ORDER BY s.station_code, wk.week_id + """) + + if not rows: + st.error("No data returned. Run seed_graph.py first.") + return + + df = pd.DataFrame(rows) + df["week_num"] = df["week_id"].map(WEEK_NUM) + df = df.dropna(subset=["week_num"]) + + stations = sorted(df["station_code"].unique()) + sel = st.multiselect("Select stations to display", stations, default=stations[:5]) + if not sel: + st.warning("Select at least one station.") + return + + fig = go.Figure() + predictions = [] + + colors_palette = [ + "#3b82f6","#f97316","#a855f7","#22c55e","#f43f5e", + "#06b6d4","#eab308","#6366f1","#14b8a6","#ec4899", + ] + + for i, sc in enumerate(sel): + sub = df[df["station_code"] == sc].sort_values("week_num") + if len(sub) < 2: + continue + station_name = sub["station_name"].iloc[0] + color = colors_palette[i % len(colors_palette)] + + x = sub["week_num"].values + y = sub["actual_hours"].values + + # Linear regression + coeffs = np.polyfit(x, y, 1) + slope, intercept = coeffs + trend_fn = np.poly1d(coeffs) + pred_w9 = float(trend_fn(9)) + + # Trend line over w1–w9 + x_trend = np.linspace(1, 9, 50) + y_trend = trend_fn(x_trend) + + # Actual data points + fig.add_trace(go.Scatter( + x=sub["week_id"], y=y, + mode="lines+markers", + name=f"{sc} actual", + line=dict(color=color, width=2), + marker=dict(size=7), + legendgroup=sc, + )) + + # Week labels for trend x-axis (w1..w9) + week_labels = [f"w{int(n)}" for n in np.round(x_trend)] + + # Trend line + fig.add_trace(go.Scatter( + x=week_labels, y=y_trend, + mode="lines", + name=f"{sc} trend", + line=dict(color=color, width=1.5, dash="dot"), + opacity=0.5, + legendgroup=sc, + showlegend=False, + )) + + # Week-9 predicted point + fig.add_trace(go.Scatter( + x=["w9"], y=[pred_w9], + mode="markers", + name=f"{sc} w9 forecast", + marker=dict(size=14, color=color, symbol="star", + line=dict(color="#fff", width=2)), + legendgroup=sc, + showlegend=True, + )) + + predictions.append({ + "Station Code": sc, + "Station": station_name, + "Trend (slope)": f"{slope:+.2f} hrs/wk", + "Week 8 Actual": f"{y[-1]:.1f} hrs", + "Week 9 Forecast": f"{max(0, pred_w9):.1f} hrs", + "Risk": "High" if pred_w9 > 60 else ("Medium" if pred_w9 > 30 else "Low"), + }) + + fig.update_layout( + template="plotly_dark", + title="Station Load Forecast — Linear Extrapolation to Week 9", + xaxis_title="Week", yaxis_title="Actual Hours", + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), + height=480, + paper_bgcolor="rgba(0,0,0,0)", + plot_bgcolor="rgba(15,17,23,0.6)", + ) + st.plotly_chart(fig, use_container_width=True) + + if predictions: + st.markdown("---") + st.subheader("Week 9 Forecast Summary") + pred_df = pd.DataFrame(predictions) + st.dataframe(pred_df, use_container_width=True, hide_index=True) + st.caption("Star markers on the chart indicate week 9 predicted load. Forecast uses simple OLS linear regression on actual hours w1–w8.") diff --git a/submissions/Dia-Vats/level6/pages_impl/page7_selftest.py b/submissions/Dia-Vats/level6/pages_impl/page7_selftest.py new file mode 100644 index 000000000..baf0cb084 --- /dev/null +++ b/submissions/Dia-Vats/level6/pages_impl/page7_selftest.py @@ -0,0 +1,137 @@ +"""Page 7 — Self-Test (runs automatically, green/red checklist, score out of 20)""" +import streamlit as st +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) +from db import run_query, get_driver + + +def _check(label: str, passed: bool, points: int, detail: str = ""): + icon = "✅" if passed else "❌" + color = "#166534" if passed else "#7f1d1d" + border = "#4ade80" if passed else "#ef4444" + pts_earned = points if passed else 0 + st.markdown( + f"""
+ {icon} {label}   + + {pts_earned}/{points} pts + {'
' + + detail + '' if detail else ''} +
""", + unsafe_allow_html=True, + ) + return pts_earned + + +def render(): + st.title("✅ Self-Test") + st.markdown("Automated graph validation — 6 checks, 20 points total. Runs on every page load.") + + total = 0 + max_score = 20 + + # ── Check 1: Neo4j connection alive (3 pts) ──────────────────────────────── + try: + driver = get_driver() + driver.verify_connectivity() + total += _check("Check 1 — Neo4j connection alive", True, 3, "Connected successfully.") + except Exception as e: + _check("Check 1 — Neo4j connection alive", False, 3, str(e)) + + # ── Check 2: node count ≥ 50 (3 pts) ────────────────────────────────────── + try: + res = run_query("MATCH (n) RETURN count(n) AS cnt") + node_count = res[0]["cnt"] if res else 0 + passed = node_count >= 50 + total += _check( + "Check 2 — Node count ≥ 50", passed, 3, + f"Found {node_count} nodes." + ) + except Exception as e: + _check("Check 2 — Node count ≥ 50", False, 3, str(e)) + + # ── Check 3: relationship count ≥ 100 (3 pts) ───────────────────────────── + try: + res = run_query("MATCH ()-[r]->() RETURN count(r) AS cnt") + rel_count = res[0]["cnt"] if res else 0 + passed = rel_count >= 100 + total += _check( + "Check 3 — Relationship count ≥ 100", passed, 3, + f"Found {rel_count} relationships." + ) + except Exception as e: + _check("Check 3 — Relationship count ≥ 100", False, 3, str(e)) + + # ── Check 4: 6+ distinct node labels (3 pts) ────────────────────────────── + try: + res = run_query("CALL db.labels() YIELD label RETURN collect(label) AS labels") + labels = res[0]["labels"] if res else [] + passed = len(labels) >= 6 + total += _check( + "Check 4 — 6+ distinct node labels", passed, 3, + f"Labels: {', '.join(sorted(labels))} ({len(labels)} total)" + ) + except Exception as e: + _check("Check 4 — 6+ distinct node labels", False, 3, str(e)) + + # ── Check 5: 8+ distinct relationship types (3 pts) ─────────────────────── + try: + res = run_query("CALL db.relationshipTypes() YIELD relationshipType RETURN collect(relationshipType) AS rels") + rels = res[0]["rels"] if res else [] + passed = len(rels) >= 8 + total += _check( + "Check 5 — 8+ distinct relationship types", passed, 3, + f"Types: {', '.join(sorted(rels))} ({len(rels)} total)" + ) + except Exception as e: + _check("Check 5 — 8+ distinct relationship types", False, 3, str(e)) + + # ── Check 6: Bottleneck query returns results (5 pts) ───────────────────── + BOTTLENECK_CYPHER = """ +MATCH (p:Project)-[:HAS_WORKORDER]->(wo:WorkOrder)-[:AT_STATION]->(s:Station) +WHERE wo.actual_hours > wo.planned_hours * 1.1 +RETURN p.project_name AS project, s.station_name AS station, + wo.planned_hours AS planned, wo.actual_hours AS actual +LIMIT 10 +""" + try: + res = run_query(BOTTLENECK_CYPHER) + passed = len(res) > 0 + detail_lines = [] + for r in res[:5]: + detail_lines.append( + f"• {r['project']} @ {r['station']} — " + f"planned {r['planned']:.0f}h actual {r['actual']:.0f}h" + ) + detail = f"Returned {len(res)} bottleneck work orders.
" + "
".join(detail_lines) + total += _check( + "Check 6 — Bottleneck query (actual > planned × 1.1)", passed, 5, + detail if passed else "Query returned 0 rows — no bottlenecks found or query mismatch." + ) + except Exception as e: + _check("Check 6 — Bottleneck query (actual > planned × 1.1)", False, 5, str(e)) + + # ── Score summary ────────────────────────────────────────────────────────── + st.markdown("---") + pct = int(total / max_score * 100) + score_color = "#4ade80" if pct >= 80 else ("#facc15" if pct >= 50 else "#ef4444") + grade = "A" if pct >= 90 else ("B" if pct >= 75 else ("C" if pct >= 50 else "F")) + + st.markdown( + f"""
+
{total} / {max_score}
+
Grade: {grade} ({pct}%)
+
""", + unsafe_allow_html=True, + ) + + if total == max_score: + st.balloons() + st.success("🎉 Perfect score! All checks passed.") + elif total >= 14: + st.info(f"Good — {total}/{max_score} points. Fix failing checks to reach 100%.") + else: + st.error(f"Only {total}/{max_score} points. Run seed_graph.py and verify your Neo4j connection.") diff --git a/submissions/Dia-Vats/level6/requirements.txt b/submissions/Dia-Vats/level6/requirements.txt new file mode 100644 index 000000000..845cdad31 --- /dev/null +++ b/submissions/Dia-Vats/level6/requirements.txt @@ -0,0 +1,6 @@ +streamlit>=1.35.0 +neo4j>=5.19.0 +pandas>=2.2.0 +plotly>=5.22.0 +python-dotenv>=1.0.1 +numpy>=1.26.0 \ No newline at end of file diff --git a/submissions/Dia-Vats/level6/seed_graph.py b/submissions/Dia-Vats/level6/seed_graph.py new file mode 100644 index 000000000..3c238aab0 --- /dev/null +++ b/submissions/Dia-Vats/level6/seed_graph.py @@ -0,0 +1,386 @@ +""" +seed_graph.py — Swedish Steel Factory Graph Seeder +Author: Dia Vats +Description: + Reads factory_production.csv, factory_workers.csv, factory_capacity.csv + and seeds a Neo4j graph database that exactly follows the schema defined + in schema.md / schema.png. + + Safe to re-run: uses MERGE everywhere, never CREATE. + WorkOrder IDs: {project_id}_{station_code}_{week}_{product_type} + e.g. P01_011_w1_IQB +""" + +import os +import csv +from itertools import groupby +from dotenv import load_dotenv +from neo4j import GraphDatabase + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +load_dotenv() + +NEO4J_URI = os.getenv("NEO4J_URI", "bolt://localhost:7687") +NEO4J_USER = os.getenv("NEO4J_USER", "neo4j") +NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD", "password") + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +PRODUCTION_CSV = os.path.join(BASE_DIR, "data", "factory_production.csv") +WORKERS_CSV = os.path.join(BASE_DIR, "data", "factory_workers.csv") +CAPACITY_CSV = os.path.join(BASE_DIR, "data", "factory_capacity.csv") + +# Production flow order (exactly as specified) +STATION_FLOW = [ + "011", "012", "013", "014", "015", + "016", "017", "018", "019", "021" +] + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def read_csv(path: str) -> list[dict]: + with open(path, newline="", encoding="utf-8") as f: + return list(csv.DictReader(f)) + + +def workorder_id(project_id: str, station_code: str, week: str, product_type: str) -> str: + return f"{project_id}_{station_code}_{week}_{product_type}" + + +def variance_pct(planned: float, actual: float) -> float: + if planned == 0: + return 0.0 + return round((actual - planned) / planned * 100, 2) + + +def is_bottleneck(planned: float, actual: float) -> bool: + return actual > planned * 1.1 + + +# --------------------------------------------------------------------------- +# Constraints +# --------------------------------------------------------------------------- + +def create_constraints(session): + print("Creating constraints …") + constraints = [ + "CREATE CONSTRAINT project_id IF NOT EXISTS FOR (n:Project) REQUIRE n.project_id IS UNIQUE", + "CREATE CONSTRAINT workorder_id IF NOT EXISTS FOR (n:WorkOrder) REQUIRE n.workorder_id IS UNIQUE", + "CREATE CONSTRAINT station_code IF NOT EXISTS FOR (n:Station) REQUIRE n.station_code IS UNIQUE", + "CREATE CONSTRAINT product_type IF NOT EXISTS FOR (n:Product) REQUIRE n.product_type IS UNIQUE", + "CREATE CONSTRAINT week_id IF NOT EXISTS FOR (n:Week) REQUIRE n.week_id IS UNIQUE", + "CREATE CONSTRAINT worker_id IF NOT EXISTS FOR (n:Worker) REQUIRE n.worker_id IS UNIQUE", + "CREATE CONSTRAINT certification_name IF NOT EXISTS FOR (n:Certification) REQUIRE n.name IS UNIQUE", + "CREATE CONSTRAINT capacity_week IF NOT EXISTS FOR (n:CapacitySnapshot) REQUIRE n.week_id IS UNIQUE", + "CREATE CONSTRAINT etapp_name IF NOT EXISTS FOR (n:Etapp) REQUIRE n.name IS UNIQUE", + ] + for cypher in constraints: + session.run(cypher) + print(" ✓ Constraints ready.") + + +# --------------------------------------------------------------------------- +# Seed: Project, WorkOrder, Station, Product, Week nodes + core relationships +# --------------------------------------------------------------------------- + +def seed_production(session, rows: list[dict]): + print("Seeding production data …") + + # Build sorted week list for FOLLOWS logic + week_sort = {"w1": 1, "w2": 2, "w3": 3, "w4": 4, "w5": 5, "w6": 6, "w7": 7, "w8": 8} + + for row in rows: + project_id = row["project_id"].strip() + project_number = row["project_number"].strip() + project_name = row["project_name"].strip() + product_type = row["product_type"].strip() + unit = row["unit"].strip() + unit_factor = float(row["unit_factor"]) + quantity = int(row["quantity"]) + station_code = row["station_code"].strip() + station_name = row["station_name"].strip() + etapp = row["etapp"].strip() + bop = row["bop"].strip() + week = row["week"].strip() + planned_hrs = float(row["planned_hours"]) + actual_hrs = float(row["actual_hours"]) + completed = int(row["completed_units"]) + + wo_id = workorder_id(project_id, station_code, week, product_type) + var = variance_pct(planned_hrs, actual_hrs) + bottleneck = is_bottleneck(planned_hrs, actual_hrs) + + session.run(""" + MERGE (p:Project {project_id: $project_id}) + SET p.project_number = $project_number, + p.project_name = $project_name, + p.etapp = $etapp, + p.bop = $bop + + MERGE (st:Station {station_code: $station_code}) + SET st.station_name = $station_name + + MERGE (pr:Product {product_type: $product_type}) + SET pr.unit = $unit, + pr.unit_factor = $unit_factor + + MERGE (wk:Week {week_id: $week}) + + MERGE (wo:WorkOrder {workorder_id: $wo_id}) + SET wo.planned_hours = $planned_hrs, + wo.actual_hours = $actual_hrs, + wo.completed_units = $completed, + wo.variance_pct = $var, + wo.is_bottleneck = $bottleneck, + wo.week = $week, + wo.station_code = $station_code, + wo.project_id = $project_id, + wo.product_type = $product_type + + MERGE (p)-[:HAS_WORKORDER]->(wo) + MERGE (wo)-[:AT_STATION]->(st) + MERGE (wo)-[:PRODUCES]->(pr) + + MERGE (wo)-[r:SCHEDULED_IN]->(wk) + SET r.planned_hours = $planned_hrs, + r.actual_hours = $actual_hrs, + r.completed_units = $completed + """, + project_id=project_id, + project_number=project_number, + project_name=project_name, + etapp=etapp, + bop=bop, + station_code=station_code, + station_name=station_name, + product_type=product_type, + unit=unit, + unit_factor=unit_factor, + week=week, + wo_id=wo_id, + planned_hrs=planned_hrs, + actual_hrs=actual_hrs, + completed=completed, + var=var, + bottleneck=bottleneck, + ) + + # (Project)-[:IN_ETAPP]->(Etapp) — ET1 and ET2 only + if etapp in ("ET1", "ET2"): + session.run(""" + MERGE (e:Etapp {name: $etapp}) + MERGE (p:Project {project_id: $project_id}) + MERGE (p)-[:IN_ETAPP]->(e) + """, etapp=etapp, project_id=project_id) + + print(f" ✓ Seeded {len(rows)} WorkOrder rows.") + + +# --------------------------------------------------------------------------- +# Seed: FEEDS_INTO between stations (production flow order) +# --------------------------------------------------------------------------- + +def seed_feeds_into(session): + print("Creating FEEDS_INTO relationships …") + for i in range(len(STATION_FLOW) - 1): + src = STATION_FLOW[i] + dst = STATION_FLOW[i + 1] + session.run(""" + MATCH (a:Station {station_code: $src}) + MATCH (b:Station {station_code: $dst}) + MERGE (a)-[:FEEDS_INTO]->(b) + """, src=src, dst=dst) + print(f" ✓ Created {len(STATION_FLOW)-1} FEEDS_INTO relationships.") + + +# --------------------------------------------------------------------------- +# Seed: FOLLOWS between WorkOrders (same project, same station, consecutive weeks) +# --------------------------------------------------------------------------- + +def seed_follows(session, rows: list[dict]): + print("Creating FOLLOWS relationships …") + week_order = {"w1": 1, "w2": 2, "w3": 3, "w4": 4, "w5": 5, "w6": 6, "w7": 7, "w8": 8} + + # Group by (project_id, station_code, product_type) + key = lambda r: (r["project_id"].strip(), r["station_code"].strip(), r["product_type"].strip()) + sorted_rows = sorted(rows, key=key) + + count = 0 + for grp_key, group in groupby(sorted_rows, key=key): + project_id, station_code, product_type = grp_key + group_list = sorted(list(group), key=lambda r: week_order.get(r["week"].strip(), 99)) + + for idx in range(len(group_list) - 1): + cur = group_list[idx] + nxt = group_list[idx + 1] + cur_week = cur["week"].strip() + nxt_week = nxt["week"].strip() + # Only link consecutive weeks + if week_order.get(nxt_week, 99) - week_order.get(cur_week, 0) == 1: + wo1 = workorder_id(project_id, station_code, cur_week, product_type) + wo2 = workorder_id(project_id, station_code, nxt_week, product_type) + session.run(""" + MATCH (a:WorkOrder {workorder_id: $wo1}) + MATCH (b:WorkOrder {workorder_id: $wo2}) + MERGE (a)-[:FOLLOWS]->(b) + """, wo1=wo1, wo2=wo2) + count += 1 + + print(f" ✓ Created {count} FOLLOWS relationships.") + + +# --------------------------------------------------------------------------- +# Seed: Workers, Certifications, ASSIGNED_TO, CAN_COVER, CERTIFIED_IN, REQUIRES +# --------------------------------------------------------------------------- + +def seed_workers(session, rows: list[dict]): + print("Seeding workers …") + for row in rows: + worker_id = row["worker_id"].strip() + name = row["name"].strip() + role = row["role"].strip() + primary_sta = row["primary_station"].strip() + can_cover = [s.strip() for s in row["can_cover_stations"].split(",")] + certs = [c.strip() for c in row["certifications"].split(",")] + hours_pw = int(row["hours_per_week"]) + wtype = row["type"].strip() + + session.run(""" + MERGE (w:Worker {worker_id: $worker_id}) + SET w.name = $name, + w.role = $role, + w.hours_per_week = $hours_pw, + w.type = $wtype + """, worker_id=worker_id, name=name, role=role, hours_pw=hours_pw, wtype=wtype) + + # ASSIGNED_TO primary station (skip "all" sentinel for Victor Elm) + if primary_sta != "all": + session.run(""" + MATCH (w:Worker {worker_id: $worker_id}) + MATCH (s:Station {station_code: $station_code}) + MERGE (w)-[:ASSIGNED_TO]->(s) + """, worker_id=worker_id, station_code=primary_sta) + + # CAN_COVER stations + for sc in can_cover: + if sc: + session.run(""" + MATCH (w:Worker {worker_id: $worker_id}) + MATCH (s:Station {station_code: $station_code}) + MERGE (w)-[:CAN_COVER]->(s) + """, worker_id=worker_id, station_code=sc) + + # CERTIFIED_IN certifications + for cert in certs: + if cert: + session.run(""" + MERGE (c:Certification {name: $cert}) + WITH c + MATCH (w:Worker {worker_id: $worker_id}) + MERGE (w)-[:CERTIFIED_IN]->(c) + """, cert=cert, worker_id=worker_id) + + # REQUIRES: link each station this worker covers to their certifications + for sc in can_cover: + if sc: + for cert in certs: + if cert: + session.run(""" + MATCH (s:Station {station_code: $station_code}) + MATCH (c:Certification {name: $cert}) + MERGE (s)-[:REQUIRES]->(c) + """, station_code=sc, cert=cert) + + print(f" ✓ Seeded {len(rows)} workers.") + + +# --------------------------------------------------------------------------- +# Seed: CapacitySnapshot + HAS_CAPACITY +# --------------------------------------------------------------------------- + +def seed_capacity(session, rows: list[dict]): + print("Seeding capacity snapshots …") + for row in rows: + week = row["week"].strip() + session.run(""" + MERGE (wk:Week {week_id: $week}) + + MERGE (cs:CapacitySnapshot {week_id: $week}) + SET cs.own_staff_count = $own_staff, + cs.hired_staff_count = $hired_staff, + cs.own_hours = $own_hours, + cs.hired_hours = $hired_hours, + cs.overtime_hours = $overtime, + cs.total_capacity = $total_cap, + cs.total_planned = $total_planned, + cs.deficit = $deficit + + MERGE (wk)-[:HAS_CAPACITY]->(cs) + """, + week=week, + own_staff=int(row["own_staff_count"]), + hired_staff=int(row["hired_staff_count"]), + own_hours=int(row["own_hours"]), + hired_hours=int(row["hired_hours"]), + overtime=int(row["overtime_hours"]), + total_cap=int(row["total_capacity"]), + total_planned=int(row["total_planned"]), + deficit=int(row["deficit"]), + ) + print(f" ✓ Seeded {len(rows)} CapacitySnapshot nodes.") + + +# --------------------------------------------------------------------------- +# Verification summary +# --------------------------------------------------------------------------- + +def print_summary(session): + print("\n--- Graph Summary ---") + for label in ["Project", "WorkOrder", "Station", "Product", "Week", + "Worker", "Certification", "CapacitySnapshot", "Etapp"]: + result = session.run(f"MATCH (n:{label}) RETURN count(n) AS cnt") + cnt = result.single()["cnt"] + print(f" {label}: {cnt}") + + result = session.run("MATCH ()-[r]->() RETURN count(r) AS cnt") + print(f" Relationships: {result.single()['cnt']}") + + result = session.run("CALL db.relationshipTypes() YIELD relationshipType RETURN collect(relationshipType)") + rels = result.single()[0] + print(f" Relationship types: {rels}") + print("---") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + print(f"Connecting to Neo4j at {NEO4J_URI} …") + driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD)) + driver.verify_connectivity() + print(" ✓ Connected.") + + production_rows = read_csv(PRODUCTION_CSV) + workers_rows = read_csv(WORKERS_CSV) + capacity_rows = read_csv(CAPACITY_CSV) + + with driver.session() as session: + create_constraints(session) + seed_production(session, production_rows) + seed_feeds_into(session) + seed_follows(session, production_rows) + seed_workers(session, workers_rows) + seed_capacity(session, capacity_rows) + print_summary(session) + + driver.close() + print("\n✅ Graph seeding complete. Safe to re-run.") + + +if __name__ == "__main__": + main()