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'', 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'', 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'', 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()