Spaces:
Sleeping
Sleeping
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,1586 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
import numpy as np
|
| 3 |
+
import pulp as pl
|
| 4 |
+
import matplotlib.pyplot as plt
|
| 5 |
+
import gradio as gr
|
| 6 |
+
from itertools import product
|
| 7 |
+
import io
|
| 8 |
+
import base64
|
| 9 |
+
import tempfile
|
| 10 |
+
import os
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
import plotly.express as px
|
| 13 |
+
from plotly.subplots import make_subplots
|
| 14 |
+
import plotly.graph_objects as go
|
| 15 |
+
import seaborn as sns
|
| 16 |
+
from ortools.sat.python import cp_model
|
| 17 |
+
import random
|
| 18 |
+
from deap import base, creator, tools, algorithms
|
| 19 |
+
import time
|
| 20 |
+
|
| 21 |
+
def am_pm(hour):
|
| 22 |
+
"""Converts 24-hour time to AM/PM format."""
|
| 23 |
+
period = "AM"
|
| 24 |
+
if hour >= 12:
|
| 25 |
+
period = "PM"
|
| 26 |
+
if hour > 12:
|
| 27 |
+
hour -= 12
|
| 28 |
+
elif hour == 0:
|
| 29 |
+
hour = 12 # Midnight
|
| 30 |
+
return f"{int(hour):02d}:00 {period}"
|
| 31 |
+
|
| 32 |
+
def show_dataframe(csv_path):
|
| 33 |
+
"""Reads a CSV file and returns a Pandas DataFrame."""
|
| 34 |
+
try:
|
| 35 |
+
df = pd.read_csv(csv_path)
|
| 36 |
+
return df
|
| 37 |
+
except Exception as e:
|
| 38 |
+
return f"Error loading CSV: {e}"
|
| 39 |
+
|
| 40 |
+
def optimize_staffing(
|
| 41 |
+
csv_file,
|
| 42 |
+
beds_per_staff,
|
| 43 |
+
max_hours_per_staff, # This will now be interpreted as hours per 28-day period
|
| 44 |
+
hours_per_cycle,
|
| 45 |
+
rest_days_per_week,
|
| 46 |
+
clinic_start,
|
| 47 |
+
clinic_end,
|
| 48 |
+
overlap_time,
|
| 49 |
+
max_start_time_change,
|
| 50 |
+
exact_staff_count=None,
|
| 51 |
+
overtime_percent=100
|
| 52 |
+
):
|
| 53 |
+
try:
|
| 54 |
+
# Load data
|
| 55 |
+
if isinstance(csv_file, str):
|
| 56 |
+
# Handle the case when a filepath is passed directly
|
| 57 |
+
data = pd.read_csv(csv_file)
|
| 58 |
+
elif hasattr(csv_file, 'name'):
|
| 59 |
+
# Handle the case when file object is uploaded through Gradio
|
| 60 |
+
data = pd.read_csv(csv_file.name)
|
| 61 |
+
elif csv_file is None:
|
| 62 |
+
# Create a default DataFrame for testing
|
| 63 |
+
days = range(1, 21) # 20 days
|
| 64 |
+
data = pd.DataFrame({'day': days})
|
| 65 |
+
# Add 4 cycles per day (5-hour cycles)
|
| 66 |
+
for cycle in range(1, 5):
|
| 67 |
+
data[f'cycle{cycle}'] = 3 # Default 3 beds per cycle
|
| 68 |
+
else:
|
| 69 |
+
# Try direct CSV reading
|
| 70 |
+
data = pd.read_csv(io.StringIO(csv_file.decode('utf-8')))
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f"Error loading CSV file: {e}")
|
| 73 |
+
# Create a default DataFrame
|
| 74 |
+
days = range(1, 21) # 20 days
|
| 75 |
+
data = pd.DataFrame({'day': days})
|
| 76 |
+
# Add 4 cycles per day (5-hour cycles)
|
| 77 |
+
for cycle in range(1, 5):
|
| 78 |
+
data[f'cycle{cycle}'] = 3 # Default 3 beds per cycle
|
| 79 |
+
print("Created default schedule with 20 days and 4 cycles per day")
|
| 80 |
+
|
| 81 |
+
# Rename the index column if necessary
|
| 82 |
+
if data.columns[0] not in ['day', 'Day', 'DAY']:
|
| 83 |
+
data = data.rename(columns={data.columns[0]: 'day'})
|
| 84 |
+
|
| 85 |
+
# Fill missing values
|
| 86 |
+
for col in data.columns:
|
| 87 |
+
if col.startswith('cycle'):
|
| 88 |
+
data[col] = data[col].fillna(0)
|
| 89 |
+
|
| 90 |
+
# Calculate clinic hours
|
| 91 |
+
if clinic_end < clinic_start:
|
| 92 |
+
clinic_hours = 24 - clinic_start + clinic_end
|
| 93 |
+
else:
|
| 94 |
+
clinic_hours = clinic_end - clinic_start
|
| 95 |
+
|
| 96 |
+
# Get number of days in the dataset
|
| 97 |
+
num_days = len(data)
|
| 98 |
+
|
| 99 |
+
# Parameters
|
| 100 |
+
BEDS_PER_STAFF = float(beds_per_staff)
|
| 101 |
+
STANDARD_PERIOD_DAYS = 30 # Standard 4-week period
|
| 102 |
+
|
| 103 |
+
# Scale MAX_HOURS_PER_STAFF based on the ratio of actual days to standard period
|
| 104 |
+
BASE_MAX_HOURS = float(max_hours_per_staff) # This is for a 28-day period
|
| 105 |
+
MAX_HOURS_PER_STAFF = BASE_MAX_HOURS * (num_days / STANDARD_PERIOD_DAYS)
|
| 106 |
+
|
| 107 |
+
# Log the adjustment for transparency
|
| 108 |
+
original_results = f"Input max hours per staff (28-day period): {BASE_MAX_HOURS}\n"
|
| 109 |
+
original_results += f"Adjusted max hours for {num_days}-day period: {MAX_HOURS_PER_STAFF:.1f}\n\n"
|
| 110 |
+
|
| 111 |
+
HOURS_PER_CYCLE = float(hours_per_cycle)
|
| 112 |
+
REST_DAYS_PER_WEEK = int(rest_days_per_week)
|
| 113 |
+
SHIFT_TYPES = [5, 10] # Modified to match 5-hour cycles
|
| 114 |
+
OVERLAP_TIME = float(overlap_time)
|
| 115 |
+
CLINIC_START = int(clinic_start)
|
| 116 |
+
CLINIC_END = int(clinic_end)
|
| 117 |
+
CLINIC_HOURS = clinic_hours
|
| 118 |
+
MAX_START_TIME_CHANGE = int(max_start_time_change)
|
| 119 |
+
OVERTIME_ALLOWED = 1 + (overtime_percent / 100) # Convert percentage to multiplier
|
| 120 |
+
|
| 121 |
+
# Calculate staff needed per cycle (beds/BEDS_PER_STAFF, rounded up)
|
| 122 |
+
for col in data.columns:
|
| 123 |
+
if col.startswith('cycle') and not col.endswith('_staff'):
|
| 124 |
+
data[f'{col}_staff'] = np.ceil(data[col] / BEDS_PER_STAFF)
|
| 125 |
+
|
| 126 |
+
# Get cycle names and number of cycles
|
| 127 |
+
cycle_cols = [col for col in data.columns if col.startswith('cycle') and not col.endswith('_staff')]
|
| 128 |
+
num_cycles = len(cycle_cols)
|
| 129 |
+
|
| 130 |
+
# Define cycle times
|
| 131 |
+
cycle_times = {}
|
| 132 |
+
for i, cycle in enumerate(cycle_cols):
|
| 133 |
+
cycle_start = (CLINIC_START + i * HOURS_PER_CYCLE) % 24
|
| 134 |
+
cycle_end = (CLINIC_START + (i + 1) * HOURS_PER_CYCLE) % 24
|
| 135 |
+
cycle_times[cycle] = (cycle_start, cycle_end)
|
| 136 |
+
|
| 137 |
+
# Get staff requirements
|
| 138 |
+
max_staff_needed = max([data[f'{cycle}_staff'].max() for cycle in cycle_cols], default=0)
|
| 139 |
+
if max_staff_needed <= 0:
|
| 140 |
+
return "Error: No staff requirements found in the input data.", None, None, None, None, None, None
|
| 141 |
+
|
| 142 |
+
# Generate all possible shifts
|
| 143 |
+
possible_shifts = []
|
| 144 |
+
for duration in SHIFT_TYPES:
|
| 145 |
+
for start_time in range(24):
|
| 146 |
+
end_time = (start_time + duration) % 24
|
| 147 |
+
|
| 148 |
+
# Create a shift with its coverage of cycles
|
| 149 |
+
shift = {
|
| 150 |
+
'id': f"{int(duration)}hr_{int(start_time):02d}",
|
| 151 |
+
'start': start_time,
|
| 152 |
+
'end': end_time,
|
| 153 |
+
'duration': duration,
|
| 154 |
+
'cycles_covered': set()
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
# Determine which cycles this shift covers
|
| 158 |
+
for cycle, (cycle_start, cycle_end) in cycle_times.items():
|
| 159 |
+
# Handle overnight cycles
|
| 160 |
+
if cycle_end < cycle_start: # overnight cycle
|
| 161 |
+
if start_time >= cycle_start or end_time <= cycle_end or (start_time < end_time and end_time > cycle_start):
|
| 162 |
+
shift['cycles_covered'].add(cycle)
|
| 163 |
+
else: # normal cycle
|
| 164 |
+
shift_end = end_time if end_time > start_time else end_time + 24
|
| 165 |
+
cycle_end_adj = cycle_end if cycle_end > cycle_start else cycle_end + 24
|
| 166 |
+
|
| 167 |
+
# Check for overlap
|
| 168 |
+
if not (shift_end <= cycle_start or start_time >= cycle_end_adj):
|
| 169 |
+
shift['cycles_covered'].add(cycle)
|
| 170 |
+
|
| 171 |
+
if shift['cycles_covered']: # Only add shifts that cover at least one cycle
|
| 172 |
+
possible_shifts.append(shift)
|
| 173 |
+
|
| 174 |
+
if not possible_shifts:
|
| 175 |
+
return "Error: No valid shifts could be generated with the given parameters.", None, None, None, None, None, None
|
| 176 |
+
|
| 177 |
+
# Estimate minimum number of staff needed - more precise calculation
|
| 178 |
+
total_staff_hours = 0
|
| 179 |
+
for _, row in data.iterrows():
|
| 180 |
+
for cycle in cycle_cols:
|
| 181 |
+
total_staff_hours += row[f'{cycle}_staff'] * HOURS_PER_CYCLE
|
| 182 |
+
|
| 183 |
+
# Calculate theoretical minimum staff with perfect utilization
|
| 184 |
+
if total_staff_hours <= 0:
|
| 185 |
+
return "Error: No staff hours required based on input data.", None, None, None, None, None, None
|
| 186 |
+
|
| 187 |
+
theoretical_min_staff = np.ceil(total_staff_hours / MAX_HOURS_PER_STAFF)
|
| 188 |
+
|
| 189 |
+
if theoretical_min_staff <= 0:
|
| 190 |
+
return "Error: Invalid staff calculation. Please check your input parameters.", None, None, None, None, None, None
|
| 191 |
+
|
| 192 |
+
# Add a small buffer for rest day constraints
|
| 193 |
+
min_staff_estimate = np.ceil(theoretical_min_staff * (7 / (7 - REST_DAYS_PER_WEEK)))
|
| 194 |
+
|
| 195 |
+
# Use exact_staff_count if provided, otherwise estimate
|
| 196 |
+
if exact_staff_count is not None and exact_staff_count > 0:
|
| 197 |
+
# When exact staff count is provided, only create that many staff in the model
|
| 198 |
+
estimated_staff = exact_staff_count
|
| 199 |
+
num_staff_to_create = exact_staff_count # Only create exactly this many staff
|
| 200 |
+
else:
|
| 201 |
+
# Add some buffer for constraints like rest days and shift changes
|
| 202 |
+
estimated_staff = max(min_staff_estimate, max_staff_needed + 1)
|
| 203 |
+
num_staff_to_create = int(estimated_staff) # Create the estimated number of staff
|
| 204 |
+
|
| 205 |
+
def optimize_schedule(num_staff, time_limit=600):
|
| 206 |
+
try:
|
| 207 |
+
# Create a binary linear programming model
|
| 208 |
+
model = pl.LpProblem("Staff_Scheduling", pl.LpMinimize)
|
| 209 |
+
|
| 210 |
+
# Decision variables
|
| 211 |
+
x = pl.LpVariable.dicts("shift",
|
| 212 |
+
[(s, d, shift['id']) for s in range(1, num_staff+1)
|
| 213 |
+
for d in range(1, num_days+1)
|
| 214 |
+
for shift in possible_shifts],
|
| 215 |
+
cat='Binary')
|
| 216 |
+
|
| 217 |
+
# Variables for tracking violations (all must be 0 in final solution)
|
| 218 |
+
timing_violations = pl.LpVariable.dicts("timing_violation",
|
| 219 |
+
[(s, d) for s in range(1, num_staff+1)
|
| 220 |
+
for d in range(2, num_days+1)],
|
| 221 |
+
lowBound=0)
|
| 222 |
+
|
| 223 |
+
rest_violations = pl.LpVariable.dicts("rest_violation",
|
| 224 |
+
[(s, d) for s in range(1, num_staff+1)
|
| 225 |
+
for d in range(1, num_days+1)],
|
| 226 |
+
lowBound=0)
|
| 227 |
+
|
| 228 |
+
consecutive_violations = pl.LpVariable.dicts("consecutive_violation",
|
| 229 |
+
[(s, d) for s in range(1, num_staff+1)
|
| 230 |
+
for d in range(1, num_days+1)],
|
| 231 |
+
lowBound=0)
|
| 232 |
+
|
| 233 |
+
hours_violations = pl.LpVariable.dicts("hours_violation",
|
| 234 |
+
[s for s in range(1, num_staff+1)],
|
| 235 |
+
lowBound=0)
|
| 236 |
+
|
| 237 |
+
coverage_violations = pl.LpVariable.dicts("coverage_violation",
|
| 238 |
+
[(d, c) for d in range(1, num_days+1)
|
| 239 |
+
for c in cycle_cols],
|
| 240 |
+
lowBound=0)
|
| 241 |
+
|
| 242 |
+
# Objective: Minimize all violations (must all be 0 for valid solution)
|
| 243 |
+
model += (pl.lpSum(timing_violations.values()) * 1000000 +
|
| 244 |
+
pl.lpSum(rest_violations.values()) * 100000 +
|
| 245 |
+
pl.lpSum(consecutive_violations.values()) * 50000 +
|
| 246 |
+
pl.lpSum(hours_violations.values()) * 10000 +
|
| 247 |
+
pl.lpSum(coverage_violations.values()) * 5000)
|
| 248 |
+
|
| 249 |
+
# 1. HARD CONSTRAINT: Timing Consistency
|
| 250 |
+
for s in range(1, num_staff+1):
|
| 251 |
+
for d in range(2, num_days+1):
|
| 252 |
+
# If working consecutive days, times must match exactly
|
| 253 |
+
for shift1 in possible_shifts:
|
| 254 |
+
for shift2 in possible_shifts:
|
| 255 |
+
if shift1['start'] != shift2['start']:
|
| 256 |
+
model += x[(s, d-1, shift1['id'])] + x[(s, d, shift2['id'])] <= 1 + timing_violations[s,d]
|
| 257 |
+
|
| 258 |
+
# 2. HARD CONSTRAINT: Rest Period (11 hours)
|
| 259 |
+
for s in range(1, num_staff+1):
|
| 260 |
+
for d in range(1, num_days):
|
| 261 |
+
for shift1 in possible_shifts:
|
| 262 |
+
for shift2 in possible_shifts:
|
| 263 |
+
if (shift2['start'] - shift1['end']) % 24 < 11:
|
| 264 |
+
model += x[(s, d, shift1['id'])] + x[(s, d+1, shift2['id'])] <= 1 + rest_violations[s,d]
|
| 265 |
+
|
| 266 |
+
# 3. HARD CONSTRAINT: Maximum Consecutive Days (6)
|
| 267 |
+
for s in range(1, num_staff+1):
|
| 268 |
+
for d in range(1, num_days-5):
|
| 269 |
+
consecutive_sum = pl.lpSum(x[(s, d+i, shift['id'])]
|
| 270 |
+
for i in range(7)
|
| 271 |
+
for shift in possible_shifts)
|
| 272 |
+
model += consecutive_sum <= 6 + consecutive_violations[s,d]
|
| 273 |
+
|
| 274 |
+
# 4. HARD CONSTRAINT: Monthly Hours
|
| 275 |
+
for s in range(1, num_staff+1):
|
| 276 |
+
monthly_hours = pl.lpSum(x[(s, d, shift['id'])] * shift['duration']
|
| 277 |
+
for d in range(1, num_days+1)
|
| 278 |
+
for shift in possible_shifts)
|
| 279 |
+
model += monthly_hours <= MAX_HOURS_PER_STAFF + hours_violations[s]
|
| 280 |
+
|
| 281 |
+
# 5. HARD CONSTRAINT: Coverage Requirements
|
| 282 |
+
for d in range(1, num_days+1):
|
| 283 |
+
day_index = d - 1
|
| 284 |
+
for cycle in cycle_cols:
|
| 285 |
+
staff_needed = data.iloc[day_index][f'{cycle}_staff']
|
| 286 |
+
covering_shifts = [shift for shift in possible_shifts if cycle in shift['cycles_covered']]
|
| 287 |
+
model += (pl.lpSum(x[(s, d, shift['id'])]
|
| 288 |
+
for s in range(1, num_staff+1)
|
| 289 |
+
for shift in covering_shifts) >=
|
| 290 |
+
staff_needed - coverage_violations[d,cycle])
|
| 291 |
+
|
| 292 |
+
# 6. Basic feasibility: One shift per day per staff
|
| 293 |
+
for s in range(1, num_staff+1):
|
| 294 |
+
for d in range(1, num_days+1):
|
| 295 |
+
model += pl.lpSum(x[(s, d, shift['id'])] for shift in possible_shifts) <= 1
|
| 296 |
+
|
| 297 |
+
# Solve with extended time limit
|
| 298 |
+
solver = pl.PULP_CBC_CMD(timeLimit=time_limit, msg=1, gapRel=0.01)
|
| 299 |
+
model.solve(solver)
|
| 300 |
+
|
| 301 |
+
# Check if a feasible solution was found
|
| 302 |
+
if model.status == pl.LpStatusOptimal:
|
| 303 |
+
# Verify ALL constraints are met (no violations)
|
| 304 |
+
total_violations = (sum(pl.value(v) for v in timing_violations.values()) +
|
| 305 |
+
sum(pl.value(v) for v in rest_violations.values()) +
|
| 306 |
+
sum(pl.value(v) for v in consecutive_violations.values()) +
|
| 307 |
+
sum(pl.value(v) for v in hours_violations.values()) +
|
| 308 |
+
sum(pl.value(v) for v in coverage_violations.values()))
|
| 309 |
+
|
| 310 |
+
if total_violations > 0:
|
| 311 |
+
print(f"Solution found but has {total_violations} constraint violations")
|
| 312 |
+
return None, None
|
| 313 |
+
|
| 314 |
+
# Extract the solution
|
| 315 |
+
schedule = []
|
| 316 |
+
for s in range(1, num_staff+1):
|
| 317 |
+
for d in range(1, num_days+1):
|
| 318 |
+
for shift in possible_shifts:
|
| 319 |
+
if pl.value(x[(s, d, shift['id'])]) == 1:
|
| 320 |
+
schedule.append({
|
| 321 |
+
'staff_id': s,
|
| 322 |
+
'day': d,
|
| 323 |
+
'shift_id': shift['id'],
|
| 324 |
+
'start': shift['start'],
|
| 325 |
+
'end': shift['end'],
|
| 326 |
+
'duration': shift['duration'],
|
| 327 |
+
'cycles_covered': list(shift['cycles_covered'])
|
| 328 |
+
})
|
| 329 |
+
|
| 330 |
+
return schedule, model.objective.value()
|
| 331 |
+
else:
|
| 332 |
+
return None, None
|
| 333 |
+
|
| 334 |
+
except Exception as e:
|
| 335 |
+
print(f"Error in optimization: {e}")
|
| 336 |
+
return None, None
|
| 337 |
+
|
| 338 |
+
# Try to solve with estimated number of staff
|
| 339 |
+
if exact_staff_count is not None and exact_staff_count > 0:
|
| 340 |
+
# If exact staff count is specified, only try with that count
|
| 341 |
+
staff_count = int(exact_staff_count)
|
| 342 |
+
results = f"Using exactly {staff_count} staff as specified...\n"
|
| 343 |
+
|
| 344 |
+
# Try to solve with exactly this many staff
|
| 345 |
+
schedule, objective = optimize_schedule(staff_count)
|
| 346 |
+
|
| 347 |
+
if schedule is None:
|
| 348 |
+
results += f"Failed to find a feasible solution with exactly {staff_count} staff.\n"
|
| 349 |
+
results += "Try increasing the staff count.\n"
|
| 350 |
+
return results, None, None, None, None, None, None
|
| 351 |
+
else:
|
| 352 |
+
# Start from theoretical minimum and work up
|
| 353 |
+
min_staff = max(1, int(theoretical_min_staff)) # Start from theoretical minimum
|
| 354 |
+
max_staff = int(min_staff_estimate) + 5 # Allow some buffer
|
| 355 |
+
|
| 356 |
+
results = f"Theoretical minimum staff needed: {theoretical_min_staff:.1f}\n"
|
| 357 |
+
results += f"Searching for minimum staff count starting from {min_staff}...\n"
|
| 358 |
+
|
| 359 |
+
# Try each staff count from min to max
|
| 360 |
+
for staff_count in range(min_staff, max_staff + 1):
|
| 361 |
+
results += f"Trying with {staff_count} staff...\n"
|
| 362 |
+
|
| 363 |
+
# Increase time limit for each attempt to give the solver more time
|
| 364 |
+
time_limit = 300 + (staff_count - min_staff) * 100 # More time for larger staff counts
|
| 365 |
+
schedule, objective = optimize_schedule(staff_count, time_limit)
|
| 366 |
+
|
| 367 |
+
if schedule is not None:
|
| 368 |
+
results += f"Found feasible solution with {staff_count} staff.\n"
|
| 369 |
+
break
|
| 370 |
+
|
| 371 |
+
if schedule is None:
|
| 372 |
+
results += "Failed to find a feasible solution with the attempted staff counts.\n"
|
| 373 |
+
results += "Try increasing the staff count manually or relaxing constraints.\n"
|
| 374 |
+
return results, None, None, None, None, None, None
|
| 375 |
+
|
| 376 |
+
results += f"Optimal solution found with {staff_count} staff\n"
|
| 377 |
+
results += f"Total staff hours: {objective}\n"
|
| 378 |
+
|
| 379 |
+
# Convert to DataFrame for analysis
|
| 380 |
+
schedule_df = pd.DataFrame(schedule)
|
| 381 |
+
|
| 382 |
+
# Analyze staff workload
|
| 383 |
+
staff_hours = {}
|
| 384 |
+
for s in range(1, staff_count+1):
|
| 385 |
+
staff_shifts = schedule_df[schedule_df['staff_id'] == s]
|
| 386 |
+
total_hours = staff_shifts['duration'].sum()
|
| 387 |
+
staff_hours[s] = total_hours
|
| 388 |
+
|
| 389 |
+
# After calculating staff hours, filter out staff with 0 hours before displaying
|
| 390 |
+
active_staff_hours = {s: hours for s, hours in staff_hours.items() if hours > 0}
|
| 391 |
+
|
| 392 |
+
results += "\nStaff Hours:\n"
|
| 393 |
+
for staff_id, hours in active_staff_hours.items():
|
| 394 |
+
utilization = (hours / MAX_HOURS_PER_STAFF) * 100
|
| 395 |
+
results += f"Staff {staff_id}: {hours} hours ({utilization:.1f}% utilization)\n"
|
| 396 |
+
# Add overtime information
|
| 397 |
+
if hours > MAX_HOURS_PER_STAFF:
|
| 398 |
+
overtime = hours - MAX_HOURS_PER_STAFF
|
| 399 |
+
overtime_percent = (overtime / MAX_HOURS_PER_STAFF) * 100
|
| 400 |
+
results += f" Overtime: {overtime:.1f} hours ({overtime_percent:.1f}%)\n"
|
| 401 |
+
|
| 402 |
+
# Use active_staff_hours for average utilization calculation
|
| 403 |
+
active_staff_count = len(active_staff_hours)
|
| 404 |
+
avg_utilization = sum(active_staff_hours.values()) / (active_staff_count * MAX_HOURS_PER_STAFF) * 100
|
| 405 |
+
results += f"\nAverage staff utilization: {avg_utilization:.1f}%\n"
|
| 406 |
+
|
| 407 |
+
# Check coverage for each day and cycle
|
| 408 |
+
coverage_check = []
|
| 409 |
+
for d in range(1, num_days+1):
|
| 410 |
+
day_index = d - 1 # 0-indexed for DataFrame
|
| 411 |
+
|
| 412 |
+
day_schedule = schedule_df[schedule_df['day'] == d]
|
| 413 |
+
|
| 414 |
+
for cycle in cycle_cols:
|
| 415 |
+
required = data.iloc[day_index][f'{cycle}_staff']
|
| 416 |
+
|
| 417 |
+
# Count staff covering this cycle
|
| 418 |
+
assigned = sum(1 for _, shift in day_schedule.iterrows()
|
| 419 |
+
if cycle in shift['cycles_covered'])
|
| 420 |
+
|
| 421 |
+
coverage_check.append({
|
| 422 |
+
'day': d,
|
| 423 |
+
'cycle': cycle,
|
| 424 |
+
'required': required,
|
| 425 |
+
'assigned': assigned,
|
| 426 |
+
'satisfied': assigned >= required
|
| 427 |
+
})
|
| 428 |
+
|
| 429 |
+
coverage_df = pd.DataFrame(coverage_check)
|
| 430 |
+
satisfaction = coverage_df['satisfied'].mean() * 100
|
| 431 |
+
results += f"Coverage satisfaction: {satisfaction:.1f}%\n"
|
| 432 |
+
|
| 433 |
+
if satisfaction < 100:
|
| 434 |
+
results += "Warning: Not all staffing requirements are met!\n"
|
| 435 |
+
unsatisfied = coverage_df[~coverage_df['satisfied']]
|
| 436 |
+
results += unsatisfied.to_string() + "\n"
|
| 437 |
+
|
| 438 |
+
# Generate detailed schedule report
|
| 439 |
+
detailed_schedule = "Detailed Schedule:\n"
|
| 440 |
+
for d in range(1, num_days+1):
|
| 441 |
+
day_schedule = schedule_df[schedule_df['day'] == d]
|
| 442 |
+
day_schedule = day_schedule.sort_values(['start'])
|
| 443 |
+
|
| 444 |
+
detailed_schedule += f"\nDay {d}:\n"
|
| 445 |
+
for _, shift in day_schedule.iterrows():
|
| 446 |
+
start_hour = shift['start']
|
| 447 |
+
end_hour = shift['end']
|
| 448 |
+
|
| 449 |
+
start_str = am_pm(start_hour)
|
| 450 |
+
end_str = am_pm(end_hour)
|
| 451 |
+
|
| 452 |
+
cycles = ", ".join(shift['cycles_covered'])
|
| 453 |
+
detailed_schedule += f" Staff {shift['staff_id']}: {start_str}-{end_str} ({shift['duration']} hrs), Cycles: {cycles}\n"
|
| 454 |
+
|
| 455 |
+
# Generate schedule visualization
|
| 456 |
+
fig, ax = plt.subplots(figsize=(15, 8))
|
| 457 |
+
|
| 458 |
+
# Prepare schedule for plotting
|
| 459 |
+
staff_days = {}
|
| 460 |
+
for s in range(1, staff_count+1):
|
| 461 |
+
staff_days[s] = [0] * num_days # 0 means off duty
|
| 462 |
+
|
| 463 |
+
for _, shift in schedule_df.iterrows():
|
| 464 |
+
staff_id = shift['staff_id']
|
| 465 |
+
day = shift['day'] - 1 # 0-indexed
|
| 466 |
+
staff_days[staff_id][day] = shift['duration']
|
| 467 |
+
|
| 468 |
+
# Plot the schedule
|
| 469 |
+
for s, hours in staff_days.items():
|
| 470 |
+
ax.bar(range(1, num_days+1), hours, label=f'Staff {s}')
|
| 471 |
+
|
| 472 |
+
ax.set_xlabel('Day')
|
| 473 |
+
ax.set_ylabel('Shift Hours')
|
| 474 |
+
ax.set_title('Staff Schedule')
|
| 475 |
+
ax.set_xticks(range(1, num_days+1))
|
| 476 |
+
ax.legend()
|
| 477 |
+
|
| 478 |
+
# Save the figure to a temporary file
|
| 479 |
+
plot_path = None
|
| 480 |
+
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
|
| 481 |
+
plt.savefig(f.name)
|
| 482 |
+
plt.close(fig)
|
| 483 |
+
plot_path = f.name
|
| 484 |
+
|
| 485 |
+
# Create a Gantt chart with advanced visuals and alternating labels - only showing active staff
|
| 486 |
+
gantt_path = create_gantt_chart(schedule_df, num_days, staff_count)
|
| 487 |
+
|
| 488 |
+
# Convert schedule to CSV data
|
| 489 |
+
schedule_df['start_ampm'] = schedule_df['start'].apply(am_pm)
|
| 490 |
+
schedule_df['end_ampm'] = schedule_df['end'].apply(am_pm)
|
| 491 |
+
schedule_csv = schedule_df[['staff_id', 'day', 'start_ampm', 'end_ampm', 'duration', 'cycles_covered']].to_csv(index=False)
|
| 492 |
+
|
| 493 |
+
# Create a temporary file and write the CSV data into it
|
| 494 |
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=".csv") as temp_file:
|
| 495 |
+
temp_file.write(schedule_csv)
|
| 496 |
+
schedule_csv_path = temp_file.name
|
| 497 |
+
|
| 498 |
+
# Create staff assignment table
|
| 499 |
+
staff_assignment_data = []
|
| 500 |
+
for d in range(1, num_days + 1):
|
| 501 |
+
cycle_staff = {}
|
| 502 |
+
for cycle in cycle_cols:
|
| 503 |
+
# Get staff IDs assigned to this cycle on this day
|
| 504 |
+
staff_ids = schedule_df[(schedule_df['day'] == d) & (schedule_df['cycles_covered'].apply(lambda x: cycle in x))]['staff_id'].tolist()
|
| 505 |
+
cycle_staff[cycle] = len(staff_ids)
|
| 506 |
+
staff_assignment_data.append([d] + [cycle_staff[cycle] for cycle in cycle_cols])
|
| 507 |
+
|
| 508 |
+
staff_assignment_df = pd.DataFrame(staff_assignment_data, columns=['Day'] + cycle_cols)
|
| 509 |
+
|
| 510 |
+
# Create CSV files for download
|
| 511 |
+
staff_assignment_csv_path = None
|
| 512 |
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix=".csv") as temp_file:
|
| 513 |
+
staff_assignment_df.to_csv(temp_file.name, index=False)
|
| 514 |
+
staff_assignment_csv_path = temp_file.name
|
| 515 |
+
|
| 516 |
+
# Return all required values in the correct order
|
| 517 |
+
return results, staff_assignment_df, gantt_path, schedule_df, plot_path, schedule_csv_path, staff_assignment_csv_path
|
| 518 |
+
|
| 519 |
+
def convert_to_24h(time_str):
|
| 520 |
+
"""Converts AM/PM time string to 24-hour format."""
|
| 521 |
+
try:
|
| 522 |
+
time_obj = datetime.strptime(time_str, "%I:00 %p")
|
| 523 |
+
return time_obj.hour
|
| 524 |
+
except ValueError:
|
| 525 |
+
return None
|
| 526 |
+
|
| 527 |
+
def gradio_wrapper(
|
| 528 |
+
csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
|
| 529 |
+
rest_days_per_week, clinic_start_ampm, clinic_end_ampm, overlap_time, max_start_time_change,
|
| 530 |
+
exact_staff_count=None, overtime_percent=100
|
| 531 |
+
):
|
| 532 |
+
try:
|
| 533 |
+
# Convert AM/PM times to 24-hour format
|
| 534 |
+
clinic_start = convert_to_24h(clinic_start_ampm)
|
| 535 |
+
clinic_end = convert_to_24h(clinic_end_ampm)
|
| 536 |
+
|
| 537 |
+
# Call the optimization function
|
| 538 |
+
results, staff_assignment_df, gantt_path, schedule_df, plot_path, schedule_csv_path, staff_assignment_csv_path = optimize_staffing(
|
| 539 |
+
csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
|
| 540 |
+
rest_days_per_week, clinic_start, clinic_end, overlap_time, max_start_time_change,
|
| 541 |
+
exact_staff_count, overtime_percent
|
| 542 |
+
)
|
| 543 |
+
|
| 544 |
+
# Return the results
|
| 545 |
+
return staff_assignment_df, gantt_path, schedule_df, plot_path, staff_assignment_csv_path, schedule_csv_path
|
| 546 |
+
except Exception as e:
|
| 547 |
+
# If there's an error in the optimization process, return a meaningful error message
|
| 548 |
+
empty_staff_df = pd.DataFrame(columns=["Day"])
|
| 549 |
+
error_message = f"Error during optimization: {str(e)}\n\nPlease try with different parameters or a simpler dataset."
|
| 550 |
+
# Return error in the first output
|
| 551 |
+
return empty_staff_df, None, None, None, None, None
|
| 552 |
+
|
| 553 |
+
# Create a Gantt chart with advanced visuals and alternating labels - only showing active staff
|
| 554 |
+
def create_gantt_chart(schedule_df, num_days, staff_count):
|
| 555 |
+
# Get the list of active staff IDs (staff who have at least one shift)
|
| 556 |
+
active_staff_ids = sorted(schedule_df['staff_id'].unique())
|
| 557 |
+
active_staff_count = len(active_staff_ids)
|
| 558 |
+
|
| 559 |
+
# Create a mapping from original staff ID to position in the chart
|
| 560 |
+
staff_position = {staff_id: i+1 for i, staff_id in enumerate(active_staff_ids)}
|
| 561 |
+
|
| 562 |
+
# Create a larger figure with higher DPI
|
| 563 |
+
plt.figure(figsize=(max(30, num_days * 1.5), max(12, active_staff_count * 0.8)), dpi=200)
|
| 564 |
+
|
| 565 |
+
# Use a more sophisticated color palette - only for active staff
|
| 566 |
+
colors = plt.cm.viridis(np.linspace(0.1, 0.9, active_staff_count))
|
| 567 |
+
|
| 568 |
+
# Set a modern style
|
| 569 |
+
plt.style.use('seaborn-v0_8-whitegrid')
|
| 570 |
+
|
| 571 |
+
# Create a new axis with a slight background color
|
| 572 |
+
|
| 573 |
+
ax = plt.gca()
|
| 574 |
+
ax.set_facecolor('#f8f9fa')
|
| 575 |
+
|
| 576 |
+
# Sort by staff then day
|
| 577 |
+
schedule_df = schedule_df.sort_values(['staff_id', 'day'])
|
| 578 |
+
|
| 579 |
+
# Plot Gantt chart - only for active staff
|
| 580 |
+
for i, staff_id in enumerate(active_staff_ids):
|
| 581 |
+
staff_shifts = schedule_df[schedule_df['staff_id'] == staff_id]
|
| 582 |
+
|
| 583 |
+
y_pos = active_staff_count - i # Position based on index in active staff list
|
| 584 |
+
|
| 585 |
+
# Add staff label with a background box
|
| 586 |
+
ax.text(-0.7, y_pos, f"Staff {staff_id}", fontsize=12, fontweight='bold',
|
| 587 |
+
ha='right', va='center', bbox=dict(facecolor='white', edgecolor='gray',
|
| 588 |
+
boxstyle='round,pad=0.5', alpha=0.9))
|
| 589 |
+
|
| 590 |
+
# Add a subtle background for each staff row
|
| 591 |
+
ax.axhspan(y_pos-0.4, y_pos+0.4, color='white', alpha=0.4, zorder=-5)
|
| 592 |
+
|
| 593 |
+
# Track shift positions to avoid label overlap
|
| 594 |
+
shift_positions = []
|
| 595 |
+
|
| 596 |
+
for idx, shift in enumerate(staff_shifts.iterrows()):
|
| 597 |
+
_, shift = shift
|
| 598 |
+
day = shift['day']
|
| 599 |
+
start_hour = shift['start']
|
| 600 |
+
end_hour = shift['end']
|
| 601 |
+
duration = shift['duration']
|
| 602 |
+
|
| 603 |
+
# Format times for display
|
| 604 |
+
start_ampm = am_pm(start_hour)
|
| 605 |
+
end_ampm = am_pm(end_hour)
|
| 606 |
+
|
| 607 |
+
# Calculate shift position
|
| 608 |
+
shift_start_pos = day-1+start_hour/24
|
| 609 |
+
|
| 610 |
+
# Handle overnight shifts
|
| 611 |
+
if end_hour < start_hour: # Overnight shift
|
| 612 |
+
# First part of shift (until midnight)
|
| 613 |
+
rect1 = ax.barh(y_pos, (24-start_hour)/24, left=shift_start_pos,
|
| 614 |
+
height=0.6, color=colors[i], alpha=0.9,
|
| 615 |
+
edgecolor='black', linewidth=1, zorder=10)
|
| 616 |
+
|
| 617 |
+
# Add gradient effect
|
| 618 |
+
for r in rect1:
|
| 619 |
+
r.set_edgecolor('black')
|
| 620 |
+
r.set_linewidth(1)
|
| 621 |
+
|
| 622 |
+
# Second part of shift (after midnight)
|
| 623 |
+
rect2 = ax.barh(y_pos, end_hour/24, left=day,
|
| 624 |
+
height=0.6, color=colors[i], alpha=0.9,
|
| 625 |
+
edgecolor='black', linewidth=1, zorder=10)
|
| 626 |
+
|
| 627 |
+
# Add gradient effect
|
| 628 |
+
for r in rect2:
|
| 629 |
+
r.set_edgecolor('black')
|
| 630 |
+
r.set_linewidth(1)
|
| 631 |
+
|
| 632 |
+
# For overnight shifts, we'll place the label in the first part if it's long enough
|
| 633 |
+
shift_width = (24-start_hour)/24
|
| 634 |
+
if shift_width >= 0.1: # Only add label if there's enough space
|
| 635 |
+
label_pos = shift_start_pos + shift_width/2
|
| 636 |
+
|
| 637 |
+
# Alternate labels above and below
|
| 638 |
+
y_offset = 0.35 if idx % 2 == 0 else -0.35
|
| 639 |
+
|
| 640 |
+
# Add label with background for better readability
|
| 641 |
+
label = f"{start_ampm}-{end_ampm}"
|
| 642 |
+
text = ax.text(label_pos, y_pos + y_offset, label,
|
| 643 |
+
ha='center', va='center', fontsize=9, fontweight='bold',
|
| 644 |
+
color='black', bbox=dict(facecolor='white', alpha=0.9, pad=3,
|
| 645 |
+
boxstyle='round,pad=0.3', edgecolor='gray'),
|
| 646 |
+
zorder=20)
|
| 647 |
+
|
| 648 |
+
shift_positions.append(label_pos)
|
| 649 |
+
else:
|
| 650 |
+
# Regular shift
|
| 651 |
+
shift_width = duration/24
|
| 652 |
+
rect = ax.barh(y_pos, shift_width, left=shift_start_pos,
|
| 653 |
+
height=0.6, color=colors[i], alpha=0.9,
|
| 654 |
+
edgecolor='black', linewidth=1, zorder=10)
|
| 655 |
+
|
| 656 |
+
# Add gradient effect
|
| 657 |
+
for r in rect:
|
| 658 |
+
r.set_edgecolor('black')
|
| 659 |
+
r.set_linewidth(1)
|
| 660 |
+
|
| 661 |
+
# Only add label if there's enough space
|
| 662 |
+
if shift_width >= 0.1:
|
| 663 |
+
label_pos = shift_start_pos + shift_width/2
|
| 664 |
+
|
| 665 |
+
# Alternate labels above and below
|
| 666 |
+
y_offset = 0.35 if idx % 2 == 0 else -0.35
|
| 667 |
+
|
| 668 |
+
# Add label with background for better readability
|
| 669 |
+
label = f"{start_ampm}-{end_ampm}"
|
| 670 |
+
text = ax.text(label_pos, y_pos + y_offset, label,
|
| 671 |
+
ha='center', va='center', fontsize=9, fontweight='bold',
|
| 672 |
+
color='black', bbox=dict(facecolor='white', alpha=0.9, pad=3,
|
| 673 |
+
boxstyle='round,pad=0.3', edgecolor='gray'),
|
| 674 |
+
zorder=20)
|
| 675 |
+
|
| 676 |
+
shift_positions.append(label_pos)
|
| 677 |
+
|
| 678 |
+
# Add weekend highlighting with a more sophisticated look
|
| 679 |
+
for day in range(1, num_days + 1):
|
| 680 |
+
# Determine if this is a weekend (assuming day 1 is Monday)
|
| 681 |
+
is_weekend = (day % 7 == 0) or (day % 7 == 6) # Saturday or Sunday
|
| 682 |
+
|
| 683 |
+
if is_weekend:
|
| 684 |
+
ax.axvspan(day-1, day, alpha=0.15, color='#ff9999', zorder=-10)
|
| 685 |
+
day_label = "Saturday" if day % 7 == 6 else "Sunday"
|
| 686 |
+
ax.text(day-0.5, 0.2, day_label, ha='center', fontsize=10, color='#cc0000',
|
| 687 |
+
fontweight='bold', bbox=dict(facecolor='white', alpha=0.7, pad=2, boxstyle='round'))
|
| 688 |
+
|
| 689 |
+
# Set x-axis ticks for each day with better formatting
|
| 690 |
+
ax.set_xticks(np.arange(0.5, num_days, 1))
|
| 691 |
+
day_labels = [f"Day {d}" for d in range(1, num_days+1)]
|
| 692 |
+
ax.set_xticklabels(day_labels, rotation=0, ha='center', fontsize=10)
|
| 693 |
+
|
| 694 |
+
# Add vertical lines between days with better styling
|
| 695 |
+
for day in range(1, num_days):
|
| 696 |
+
ax.axvline(x=day, color='#aaaaaa', linestyle='-', alpha=0.5, zorder=-5)
|
| 697 |
+
|
| 698 |
+
# Set y-axis ticks for each staff
|
| 699 |
+
ax.set_yticks(np.arange(1, active_staff_count+1))
|
| 700 |
+
ax.set_yticklabels([]) # Remove default labels as we've added custom ones
|
| 701 |
+
|
| 702 |
+
# Set axis limits with some padding
|
| 703 |
+
ax.set_xlim(-0.8, num_days)
|
| 704 |
+
ax.set_ylim(0.5, active_staff_count + 0.5)
|
| 705 |
+
|
| 706 |
+
# Add grid for hours (every 6 hours) with better styling
|
| 707 |
+
for day in range(num_days):
|
| 708 |
+
for hour in [6, 12, 18]:
|
| 709 |
+
ax.axvline(x=day + hour/24, color='#cccccc', linestyle=':', alpha=0.5, zorder=-5)
|
| 710 |
+
# Add small hour markers at the bottom
|
| 711 |
+
hour_label = "6AM" if hour == 6 else "Noon" if hour == 12 else "6PM"
|
| 712 |
+
ax.text(day + hour/24, 0, hour_label, ha='center', va='bottom', fontsize=7,
|
| 713 |
+
color='#666666', rotation=90, alpha=0.7)
|
| 714 |
+
|
| 715 |
+
# Add title and labels with more sophisticated styling
|
| 716 |
+
plt.title(f'Staff Schedule ({active_staff_count} Active Staff)', fontsize=24, fontweight='bold', pad=20, color='#333333')
|
| 717 |
+
plt.xlabel('Day', fontsize=16, labelpad=10, color='#333333')
|
| 718 |
+
|
| 719 |
+
# Add a legend for time reference with better styling
|
| 720 |
+
time_box = plt.figtext(0.01, 0.01, "Time Reference:", ha='left', fontsize=10,
|
| 721 |
+
fontweight='bold', color='#333333')
|
| 722 |
+
time_markers = ['6 AM', 'Noon', '6 PM', 'Midnight']
|
| 723 |
+
for i, time in enumerate(time_markers):
|
| 724 |
+
plt.figtext(0.08 + i*0.06, 0.01, time, ha='left', fontsize=9, color='#555555')
|
| 725 |
+
|
| 726 |
+
# Remove spines
|
| 727 |
+
for spine in ['top', 'right', 'left']:
|
| 728 |
+
ax.spines[spine].set_visible(False)
|
| 729 |
+
|
| 730 |
+
# Add a note about weekends with better styling
|
| 731 |
+
weekend_note = plt.figtext(0.01, 0.97, "Red areas = Weekends", fontsize=12,
|
| 732 |
+
color='#cc0000', fontweight='bold',
|
| 733 |
+
bbox=dict(facecolor='white', alpha=0.7, pad=5, boxstyle='round'))
|
| 734 |
+
|
| 735 |
+
# Add a subtle border around the entire chart
|
| 736 |
+
plt.box(False)
|
| 737 |
+
|
| 738 |
+
# Save the Gantt chart with high quality
|
| 739 |
+
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
|
| 740 |
+
plt.tight_layout()
|
| 741 |
+
plt.savefig(f.name, dpi=200, bbox_inches='tight', facecolor='white')
|
| 742 |
+
plt.close()
|
| 743 |
+
return f.name
|
| 744 |
+
|
| 745 |
+
# Define Gradio UI
|
| 746 |
+
am_pm_times = [f"{i:02d}:00 AM" for i in range(1, 13)] + [f"{i:02d}:00 PM" for i in range(1, 13)]
|
| 747 |
+
|
| 748 |
+
# Add CSS for chart containers
|
| 749 |
+
css = """
|
| 750 |
+
.chart-container {
|
| 751 |
+
height: 800px !important;
|
| 752 |
+
width: 100% !important;
|
| 753 |
+
margin: 20px 0;
|
| 754 |
+
padding: 20px;
|
| 755 |
+
border: 1px solid #ddd;
|
| 756 |
+
border-radius: 8px;
|
| 757 |
+
background: white;
|
| 758 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
.weekly-chart-container {
|
| 762 |
+
height: 1000px !important;
|
| 763 |
+
width: 100% !important;
|
| 764 |
+
margin: 20px 0;
|
| 765 |
+
padding: 20px;
|
| 766 |
+
border: 1px solid #ddd;
|
| 767 |
+
border-radius: 8px;
|
| 768 |
+
background: white;
|
| 769 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
/* Ensure plotly charts are visible */
|
| 773 |
+
.js-plotly-plot {
|
| 774 |
+
width: 100% !important;
|
| 775 |
+
height: 100% !important;
|
| 776 |
+
}
|
| 777 |
+
|
| 778 |
+
/* Improve visibility of chart titles */
|
| 779 |
+
.gtitle {
|
| 780 |
+
font-weight: bold !important;
|
| 781 |
+
font-size: 20px !important;
|
| 782 |
+
}
|
| 783 |
+
"""
|
| 784 |
+
|
| 785 |
+
with gr.Blocks(title="Staff Scheduling Optimizer", css=css) as iface:
|
| 786 |
+
|
| 787 |
+
gr.Markdown("# Staff Scheduling Optimizer")
|
| 788 |
+
gr.Markdown("Upload a CSV file with cycle data and configure parameters to generate an optimal staff schedule.")
|
| 789 |
+
|
| 790 |
+
with gr.Row():
|
| 791 |
+
# LEFT PANEL - Inputs
|
| 792 |
+
with gr.Column(scale=1):
|
| 793 |
+
gr.Markdown("### Input Parameters")
|
| 794 |
+
|
| 795 |
+
# Input parameters
|
| 796 |
+
csv_input = gr.File(label="Upload CSV")
|
| 797 |
+
beds_per_staff = gr.Number(label="Beds per Staff", value=3)
|
| 798 |
+
max_hours_per_staff = gr.Number(label="Maximum monthly hours", value=160)
|
| 799 |
+
hours_per_cycle = gr.Number(label="Hours per Cycle", value=4)
|
| 800 |
+
rest_days_per_week = gr.Number(label="Rest Days per Week", value=2)
|
| 801 |
+
clinic_start_ampm = gr.Dropdown(label="Clinic Start Hour (AM/PM)", choices=am_pm_times, value="08:00 AM")
|
| 802 |
+
clinic_end_ampm = gr.Dropdown(label="Clinic End Hour (AM/PM)", choices=am_pm_times, value="08:00 PM")
|
| 803 |
+
overlap_time = gr.Number(label="Overlap Time", value=0)
|
| 804 |
+
max_start_time_change = gr.Number(label="Max Start Time Change", value=2)
|
| 805 |
+
exact_staff_count = gr.Number(label="Exact Staff Count (optional)", value=None)
|
| 806 |
+
overtime_percent = gr.Slider(label="Overtime Allowed (%)", minimum=0, maximum=100, value=100, step=10)
|
| 807 |
+
|
| 808 |
+
optimize_btn = gr.Button("Optimize Schedule", variant="primary", size="lg")
|
| 809 |
+
|
| 810 |
+
# RIGHT PANEL - Outputs
|
| 811 |
+
with gr.Column(scale=2):
|
| 812 |
+
gr.Markdown("### Results")
|
| 813 |
+
|
| 814 |
+
# Tabs for different outputs - reordered
|
| 815 |
+
with gr.Tabs():
|
| 816 |
+
with gr.TabItem("Detailed Schedule"):
|
| 817 |
+
with gr.Row():
|
| 818 |
+
csv_schedule = gr.Dataframe(label="Detailed Schedule", elem_id="csv_schedule")
|
| 819 |
+
|
| 820 |
+
with gr.Row():
|
| 821 |
+
schedule_download_file = gr.File(label="Download Detailed Schedule", visible=True)
|
| 822 |
+
|
| 823 |
+
with gr.TabItem("Gantt Chart"):
|
| 824 |
+
gantt_chart = gr.Image(label="Staff Schedule Visualization", elem_id="gantt_chart")
|
| 825 |
+
|
| 826 |
+
with gr.TabItem("Staff Coverage by Cycle"):
|
| 827 |
+
with gr.Row():
|
| 828 |
+
staff_assignment_table = gr.Dataframe(label="Staff Count in Each Cycle (Staff May Overlap)", elem_id="staff_assignment_table")
|
| 829 |
+
|
| 830 |
+
with gr.Row():
|
| 831 |
+
staff_download_file = gr.File(label="Download Coverage Table", visible=True)
|
| 832 |
+
|
| 833 |
+
with gr.TabItem("Constraints and Analytics"):
|
| 834 |
+
with gr.Row():
|
| 835 |
+
with gr.Column(scale=1):
|
| 836 |
+
gr.Markdown("### Applied Constraints")
|
| 837 |
+
constraints_text = gr.TextArea(
|
| 838 |
+
label="",
|
| 839 |
+
interactive=False,
|
| 840 |
+
show_label=False
|
| 841 |
+
)
|
| 842 |
+
|
| 843 |
+
with gr.Row():
|
| 844 |
+
with gr.Column(scale=1):
|
| 845 |
+
gr.Markdown("### Monthly Distribution")
|
| 846 |
+
monthly_chart = gr.HTML(
|
| 847 |
+
label="Monthly Hours Distribution",
|
| 848 |
+
show_label=False,
|
| 849 |
+
elem_classes="chart-container"
|
| 850 |
+
)
|
| 851 |
+
|
| 852 |
+
with gr.Row():
|
| 853 |
+
with gr.Column(scale=1):
|
| 854 |
+
gr.Markdown("### Weekly Distribution")
|
| 855 |
+
weekly_charts = gr.HTML(
|
| 856 |
+
label="Weekly Hours Distribution",
|
| 857 |
+
show_label=False,
|
| 858 |
+
elem_classes="weekly-chart-container"
|
| 859 |
+
)
|
| 860 |
+
|
| 861 |
+
with gr.TabItem("Staff Overlap"):
|
| 862 |
+
with gr.Row():
|
| 863 |
+
overlap_chart = gr.HTML(
|
| 864 |
+
label="Staff Overlap Visualization",
|
| 865 |
+
show_label=False
|
| 866 |
+
)
|
| 867 |
+
with gr.Row():
|
| 868 |
+
gr.Markdown("""
|
| 869 |
+
This heatmap shows the number of staff members working simultaneously throughout each day.
|
| 870 |
+
- Darker colors indicate more staff overlap
|
| 871 |
+
- The x-axis shows time of day in 30-minute intervals
|
| 872 |
+
- The y-axis shows each day of the schedule
|
| 873 |
+
""")
|
| 874 |
+
|
| 875 |
+
with gr.TabItem("Staff Absence Handler"):
|
| 876 |
+
with gr.Row():
|
| 877 |
+
with gr.Column():
|
| 878 |
+
gr.Markdown("### Handle Staff Absence")
|
| 879 |
+
absent_staff = gr.Number(label="Staff ID to be absent", precision=0)
|
| 880 |
+
absence_start = gr.Number(label="Start Day", precision=0)
|
| 881 |
+
absence_end = gr.Number(label="End Day", precision=0)
|
| 882 |
+
handle_absence_btn = gr.Button("Redistribute Shifts", variant="primary")
|
| 883 |
+
|
| 884 |
+
with gr.Column():
|
| 885 |
+
absence_result = gr.TextArea(label="Redistribution Results", interactive=False)
|
| 886 |
+
updated_schedule = gr.DataFrame(label="Updated Schedule")
|
| 887 |
+
absence_gantt_chart = gr.Image(label="Absence Schedule Visualization", elem_id="absence_gantt_chart")
|
| 888 |
+
|
| 889 |
+
# Define download functions
|
| 890 |
+
def create_download_link(df, filename="data.csv"):
|
| 891 |
+
"""Create a CSV download link for a dataframe"""
|
| 892 |
+
if df is None or df.empty:
|
| 893 |
+
return None
|
| 894 |
+
|
| 895 |
+
csv_data = df.to_csv(index=False)
|
| 896 |
+
with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.csv') as f:
|
| 897 |
+
f.write(csv_data)
|
| 898 |
+
return f.name
|
| 899 |
+
|
| 900 |
+
# Update the optimize_and_display function
|
| 901 |
+
def optimize_and_display(csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
|
| 902 |
+
rest_days_per_week, clinic_start_ampm, clinic_end_ampm,
|
| 903 |
+
overlap_time, max_start_time_change, exact_staff_count, overtime_percent):
|
| 904 |
+
try:
|
| 905 |
+
# Convert AM/PM times to 24-hour format
|
| 906 |
+
clinic_start = convert_to_24h(clinic_start_ampm)
|
| 907 |
+
clinic_end = convert_to_24h(clinic_end_ampm)
|
| 908 |
+
|
| 909 |
+
# Call the optimization function
|
| 910 |
+
results, staff_assignment_df, gantt_path, schedule_df, plot_path, schedule_csv_path, staff_assignment_csv_path = optimize_staffing(
|
| 911 |
+
csv_file, beds_per_staff, max_hours_per_staff, hours_per_cycle,
|
| 912 |
+
rest_days_per_week, clinic_start, clinic_end, overlap_time, max_start_time_change,
|
| 913 |
+
exact_staff_count, overtime_percent
|
| 914 |
+
)
|
| 915 |
+
|
| 916 |
+
if schedule_df is not None:
|
| 917 |
+
try:
|
| 918 |
+
# Generate analytics data
|
| 919 |
+
constraints_info = get_constraints_summary(
|
| 920 |
+
max_hours_per_staff,
|
| 921 |
+
rest_days_per_week,
|
| 922 |
+
overtime_percent
|
| 923 |
+
)
|
| 924 |
+
|
| 925 |
+
# Create visualizations directly as HTML
|
| 926 |
+
monthly_html = create_monthly_distribution_chart(schedule_df)
|
| 927 |
+
weekly_html = create_weekly_distribution_charts(schedule_df)
|
| 928 |
+
overlap_html = create_overlap_visualization(schedule_df)
|
| 929 |
+
|
| 930 |
+
return (
|
| 931 |
+
staff_assignment_df,
|
| 932 |
+
gantt_path,
|
| 933 |
+
schedule_df,
|
| 934 |
+
schedule_csv_path,
|
| 935 |
+
constraints_info,
|
| 936 |
+
monthly_html,
|
| 937 |
+
weekly_html,
|
| 938 |
+
overlap_html
|
| 939 |
+
)
|
| 940 |
+
except Exception as e:
|
| 941 |
+
print(f"Error in visualization: {str(e)}")
|
| 942 |
+
return (
|
| 943 |
+
staff_assignment_df,
|
| 944 |
+
gantt_path,
|
| 945 |
+
schedule_df,
|
| 946 |
+
schedule_csv_path,
|
| 947 |
+
"Error in constraints",
|
| 948 |
+
"<div>Error creating monthly chart</div>",
|
| 949 |
+
"<div>Error creating weekly charts</div>",
|
| 950 |
+
"<div>Error creating overlap visualization</div>"
|
| 951 |
+
)
|
| 952 |
+
else:
|
| 953 |
+
return (None,) * 8
|
| 954 |
+
|
| 955 |
+
except Exception as e:
|
| 956 |
+
print(f"Error in optimization: {str(e)}")
|
| 957 |
+
return (None,) * 8
|
| 958 |
+
|
| 959 |
+
def get_constraints_summary(max_hours, rest_days, overtime_percent):
|
| 960 |
+
"""Generate a summary of all applied constraints from actual parameters"""
|
| 961 |
+
constraints = [
|
| 962 |
+
"Applied Scheduling Constraints:",
|
| 963 |
+
"----------------------------",
|
| 964 |
+
f"1. Maximum Hours per Month: {max_hours} hours",
|
| 965 |
+
f"2. Required Rest Days per Week: {rest_days} days",
|
| 966 |
+
f"3. Maximum Weekly Hours: 60 hours per staff member",
|
| 967 |
+
"4. Minimum Rest Period: 11 hours between shifts",
|
| 968 |
+
"5. Maximum Consecutive Days: 6 working days",
|
| 969 |
+
f"6. Overtime Allowance: {overtime_percent}% of standard hours",
|
| 970 |
+
"7. Coverage Requirements:",
|
| 971 |
+
" - All cycles must be fully staffed",
|
| 972 |
+
" - No understaffing allowed",
|
| 973 |
+
" - Staff assigned based on required beds/staff ratio",
|
| 974 |
+
"8. Shift Constraints:",
|
| 975 |
+
" - Available shift durations: 5, 10 hours",
|
| 976 |
+
" - Shifts must align with cycle times",
|
| 977 |
+
"9. Staff Scheduling Rules:",
|
| 978 |
+
" - Equal distribution of workload when possible",
|
| 979 |
+
" - Consistent shift patterns preferred",
|
| 980 |
+
" - Weekend rotations distributed fairly"
|
| 981 |
+
]
|
| 982 |
+
return "\n".join(constraints)
|
| 983 |
+
|
| 984 |
+
def create_monthly_distribution_chart(schedule_df):
|
| 985 |
+
"""Create Seaborn pie chart for monthly hours distribution"""
|
| 986 |
+
if schedule_df is None or schedule_df.empty:
|
| 987 |
+
return "<div>No data available for visualization</div>"
|
| 988 |
+
|
| 989 |
+
try:
|
| 990 |
+
# Calculate total hours per staff member
|
| 991 |
+
staff_hours = schedule_df.groupby('staff_id')['duration'].sum()
|
| 992 |
+
|
| 993 |
+
# Create pie chart
|
| 994 |
+
fig, ax = plt.subplots(figsize=(8, 8))
|
| 995 |
+
sns.set_palette("pastel")
|
| 996 |
+
ax.pie(staff_hours, labels=staff_hours.index, autopct='%1.1f%%', startangle=90)
|
| 997 |
+
ax.axis('equal') # Equal aspect ratio ensures that pie is drawn as a circle.
|
| 998 |
+
plt.title("Monthly Hours Distribution")
|
| 999 |
+
|
| 1000 |
+
# Convert plot to PNG image
|
| 1001 |
+
img = io.BytesIO()
|
| 1002 |
+
plt.savefig(img, format='png', bbox_inches='tight') # Added bbox_inches='tight'
|
| 1003 |
+
plt.close(fig)
|
| 1004 |
+
img.seek(0)
|
| 1005 |
+
|
| 1006 |
+
# Encode to base64
|
| 1007 |
+
img_base64 = base64.b64encode(img.read()).decode('utf-8')
|
| 1008 |
+
img_html = f'<img src="data:image/png;base64,{img_base64}" style="max-width:100%; max-height:600px;">'
|
| 1009 |
+
|
| 1010 |
+
return img_html
|
| 1011 |
+
except Exception as e:
|
| 1012 |
+
print(f"Error in monthly chart: {e}")
|
| 1013 |
+
return f"<div>Error creating monthly chart: {str(e)}</div>"
|
| 1014 |
+
|
| 1015 |
+
def create_weekly_distribution_charts(schedule_df):
|
| 1016 |
+
"""Create Plotly pie charts for weekly hours distribution"""
|
| 1017 |
+
if schedule_df is None or schedule_df.empty:
|
| 1018 |
+
return "<div>No data available for visualization</div>"
|
| 1019 |
+
|
| 1020 |
+
try:
|
| 1021 |
+
# Calculate total hours per staff member for each week
|
| 1022 |
+
schedule_df['week'] = schedule_df['day'] // 7 # Assuming each week starts on day 0, 7, 14, etc.
|
| 1023 |
+
weekly_hours = schedule_df.groupby(['week', 'staff_id'])['duration'].sum().reset_index()
|
| 1024 |
+
|
| 1025 |
+
# Create staff labels
|
| 1026 |
+
weekly_hours['staff_label'] = weekly_hours.apply(
|
| 1027 |
+
lambda x: f"Staff {x['staff_id']} ({x['duration']:.1f}hrs)",
|
| 1028 |
+
axis=1
|
| 1029 |
+
)
|
| 1030 |
+
|
| 1031 |
+
# Get unique weeks
|
| 1032 |
+
weeks = sorted(weekly_hours['week'].unique())
|
| 1033 |
+
|
| 1034 |
+
# Define color palette
|
| 1035 |
+
colors = px.colors.qualitative.Set3
|
| 1036 |
+
|
| 1037 |
+
# Create subplots
|
| 1038 |
+
fig = make_subplots(
|
| 1039 |
+
rows=len(weeks),
|
| 1040 |
+
cols=1,
|
| 1041 |
+
subplot_titles=[f'Week {week}' for week in weeks],
|
| 1042 |
+
specs=[[{'type': 'domain'}] for week in weeks]
|
| 1043 |
+
)
|
| 1044 |
+
|
| 1045 |
+
# Add pie charts for each week
|
| 1046 |
+
for i, week in enumerate(weeks, start=1):
|
| 1047 |
+
week_data = weekly_hours[weekly_hours['week'] == week]
|
| 1048 |
+
|
| 1049 |
+
fig.add_trace(
|
| 1050 |
+
go.Pie(
|
| 1051 |
+
values=week_data['duration'],
|
| 1052 |
+
labels=week_data['staff_label'],
|
| 1053 |
+
name=f'Week {week}',
|
| 1054 |
+
showlegend=(i == 1),
|
| 1055 |
+
marker_colors=colors,
|
| 1056 |
+
textposition='inside',
|
| 1057 |
+
textinfo='percent+label',
|
| 1058 |
+
hovertemplate=(
|
| 1059 |
+
"Staff: %{label}<br>"
|
| 1060 |
+
"Hours: %{value:.1f}<br>"
|
| 1061 |
+
"Percentage: %{percent:.1f}%"
|
| 1062 |
+
"<extra></extra>"
|
| 1063 |
+
)
|
| 1064 |
+
),
|
| 1065 |
+
row=i,
|
| 1066 |
+
col=1
|
| 1067 |
+
)
|
| 1068 |
+
|
| 1069 |
+
fig.update_layout(
|
| 1070 |
+
height=300 * len(weeks),
|
| 1071 |
+
width=800,
|
| 1072 |
+
title_text="Weekly Hours Distribution",
|
| 1073 |
+
title_x=0.5,
|
| 1074 |
+
title_font_size=20,
|
| 1075 |
+
margin=dict(t=50, l=50, r=50, b=50),
|
| 1076 |
+
showlegend=True
|
| 1077 |
+
)
|
| 1078 |
+
|
| 1079 |
+
return fig.to_html(include_plotlyjs='cdn', full_html=False)
|
| 1080 |
+
except Exception as e:
|
| 1081 |
+
print(f"Error in weekly charts: {e}")
|
| 1082 |
+
return f"<div>Error creating weekly charts: {str(e)}</div>"
|
| 1083 |
+
|
| 1084 |
+
# Add this new function for creating the overlap visualization
|
| 1085 |
+
def create_overlap_visualization(schedule_df):
|
| 1086 |
+
"""Create Seaborn heatmap for staff overlap"""
|
| 1087 |
+
if schedule_df is None or schedule_df.empty:
|
| 1088 |
+
return "<div>No data available for visualization</div>"
|
| 1089 |
+
|
| 1090 |
+
try:
|
| 1091 |
+
# Create 24-hour timeline with 30-minute intervals
|
| 1092 |
+
intervals = 48 # 24 hours * 2 (30-minute intervals)
|
| 1093 |
+
days = sorted(schedule_df['day'].unique())
|
| 1094 |
+
|
| 1095 |
+
# Initialize overlap matrix
|
| 1096 |
+
overlap_data = np.zeros((len(days), intervals))
|
| 1097 |
+
|
| 1098 |
+
# Calculate overlaps
|
| 1099 |
+
for day_idx, day in enumerate(days):
|
| 1100 |
+
day_shifts = schedule_df[schedule_df['day'] == day]
|
| 1101 |
+
|
| 1102 |
+
for i in range(intervals):
|
| 1103 |
+
time = i * 0.5
|
| 1104 |
+
staff_working = 0
|
| 1105 |
+
|
| 1106 |
+
for _, shift in day_shifts.iterrows():
|
| 1107 |
+
start = shift['start']
|
| 1108 |
+
end = shift['end']
|
| 1109 |
+
|
| 1110 |
+
if end < start: # Overnight shift
|
| 1111 |
+
if time >= start or time < end:
|
| 1112 |
+
staff_working += 1
|
| 1113 |
+
else:
|
| 1114 |
+
if start <= time < end:
|
| 1115 |
+
staff_working += 1
|
| 1116 |
+
|
| 1117 |
+
overlap_data[day_idx, i] = staff_working
|
| 1118 |
+
|
| 1119 |
+
# Create time labels
|
| 1120 |
+
time_labels = [f"{int(i//2):02d}:{int((i%2)*30):02d}" for i in range(intervals)]
|
| 1121 |
+
|
| 1122 |
+
# Create heatmap
|
| 1123 |
+
fig, ax = plt.subplots(figsize=(12, 8))
|
| 1124 |
+
sns.heatmap(overlap_data, cmap="viridis", ax=ax, cbar_kws={'label': 'Staff Count'})
|
| 1125 |
+
|
| 1126 |
+
# Set labels
|
| 1127 |
+
ax.set_xticks(np.arange(len(time_labels[::4])))
|
| 1128 |
+
ax.set_xticklabels(time_labels[::4], rotation=45, ha="right")
|
| 1129 |
+
ax.set_yticks(np.arange(len(days)))
|
| 1130 |
+
ax.set_yticklabels(days)
|
| 1131 |
+
|
| 1132 |
+
# Add title
|
| 1133 |
+
ax.set_title("Staff Overlap Throughout the Day")
|
| 1134 |
+
|
| 1135 |
+
# Ensure layout is tight
|
| 1136 |
+
plt.tight_layout()
|
| 1137 |
+
|
| 1138 |
+
# Convert plot to PNG image
|
| 1139 |
+
img = io.BytesIO()
|
| 1140 |
+
plt.savefig(img, format='png', bbox_inches='tight') # Added bbox_inches='tight'
|
| 1141 |
+
plt.close(fig)
|
| 1142 |
+
img.seek(0)
|
| 1143 |
+
|
| 1144 |
+
# Encode to base64
|
| 1145 |
+
img_base64 = base64.b64encode(img.read()).decode('utf-8')
|
| 1146 |
+
img_html = f'<img src="data:image/png;base64,{img_base64}" style="max-width:100%; max-height:800px;">'
|
| 1147 |
+
|
| 1148 |
+
return img_html
|
| 1149 |
+
except Exception as e:
|
| 1150 |
+
print(f"Error in overlap visualization: {e}")
|
| 1151 |
+
return f"<div>Error creating overlap visualization: {str(e)}</div>"
|
| 1152 |
+
|
| 1153 |
+
# Connect the button to the optimization function
|
| 1154 |
+
optimize_btn.click(
|
| 1155 |
+
fn=optimize_and_display,
|
| 1156 |
+
inputs=[
|
| 1157 |
+
csv_input, beds_per_staff, max_hours_per_staff, hours_per_cycle,
|
| 1158 |
+
rest_days_per_week, clinic_start_ampm, clinic_end_ampm,
|
| 1159 |
+
overlap_time, max_start_time_change, exact_staff_count, overtime_percent
|
| 1160 |
+
],
|
| 1161 |
+
outputs=[
|
| 1162 |
+
staff_assignment_table, # Staff coverage table
|
| 1163 |
+
gantt_chart, # Gantt chart
|
| 1164 |
+
csv_schedule, # Detailed schedule
|
| 1165 |
+
schedule_download_file, # Download file
|
| 1166 |
+
constraints_text, # Constraints text
|
| 1167 |
+
monthly_chart, # Monthly distribution
|
| 1168 |
+
weekly_charts, # Weekly distribution
|
| 1169 |
+
overlap_chart # Staff overlap visualization
|
| 1170 |
+
]
|
| 1171 |
+
)
|
| 1172 |
+
|
| 1173 |
+
# Add the handler function
|
| 1174 |
+
def handle_absence_click(staff_id, start_day, end_day, current_schedule, max_hours_per_staff, overtime_percent):
|
| 1175 |
+
if current_schedule is None or current_schedule.empty:
|
| 1176 |
+
return "No current schedule loaded.", None, None
|
| 1177 |
+
|
| 1178 |
+
absence_dates = list(range(int(start_day), int(end_day) + 1))
|
| 1179 |
+
summary, absence_schedule, absence_gantt_path = handle_staff_absence(
|
| 1180 |
+
current_schedule,
|
| 1181 |
+
int(staff_id),
|
| 1182 |
+
absence_dates,
|
| 1183 |
+
max_hours_per_staff,
|
| 1184 |
+
overtime_percent
|
| 1185 |
+
)
|
| 1186 |
+
|
| 1187 |
+
return summary, absence_schedule, absence_gantt_path
|
| 1188 |
+
|
| 1189 |
+
# Connect the absence handler button
|
| 1190 |
+
handle_absence_btn.click(
|
| 1191 |
+
fn=handle_absence_click,
|
| 1192 |
+
inputs=[
|
| 1193 |
+
absent_staff,
|
| 1194 |
+
absence_start,
|
| 1195 |
+
absence_end,
|
| 1196 |
+
csv_schedule, # Current schedule
|
| 1197 |
+
max_hours_per_staff, # Add this parameter
|
| 1198 |
+
overtime_percent # Add this parameter
|
| 1199 |
+
],
|
| 1200 |
+
outputs=[
|
| 1201 |
+
absence_result,
|
| 1202 |
+
updated_schedule,
|
| 1203 |
+
absence_gantt_chart
|
| 1204 |
+
]
|
| 1205 |
+
)
|
| 1206 |
+
|
| 1207 |
+
# Launch the Gradio app
|
| 1208 |
+
iface.launch(share=True)
|
| 1209 |
+
|
| 1210 |
+
def create_interface():
|
| 1211 |
+
with gr.Blocks() as demo:
|
| 1212 |
+
gr.Markdown("# NEF Scheduling System")
|
| 1213 |
+
|
| 1214 |
+
with gr.Tabs() as tabs:
|
| 1215 |
+
with gr.Tab("Schedule Input"):
|
| 1216 |
+
# Schedule input components
|
| 1217 |
+
with gr.Row():
|
| 1218 |
+
csv_input = gr.File(label="Upload Schedule Data (CSV)")
|
| 1219 |
+
schedule_preview = gr.DataFrame(label="Schedule Preview")
|
| 1220 |
+
|
| 1221 |
+
with gr.Tab("Schedule Output"):
|
| 1222 |
+
# Schedule output components
|
| 1223 |
+
with gr.Row():
|
| 1224 |
+
schedule_output = gr.DataFrame(label="Generated Schedule")
|
| 1225 |
+
download_btn = gr.Button("Download Schedule")
|
| 1226 |
+
|
| 1227 |
+
with gr.Tab("Constraints and Analytics"):
|
| 1228 |
+
with gr.Row():
|
| 1229 |
+
with gr.Column():
|
| 1230 |
+
gr.Markdown("### Applied Constraints")
|
| 1231 |
+
constraints_text = gr.TextArea(label="", interactive=False)
|
| 1232 |
+
|
| 1233 |
+
with gr.Row():
|
| 1234 |
+
with gr.Column():
|
| 1235 |
+
gr.Markdown("### Monthly Distribution")
|
| 1236 |
+
monthly_chart = gr.HTML(label="Monthly Hours Distribution")
|
| 1237 |
+
|
| 1238 |
+
with gr.Row():
|
| 1239 |
+
with gr.Column():
|
| 1240 |
+
gr.Markdown("### Weekly Distribution")
|
| 1241 |
+
weekly_charts = gr.HTML(label="Weekly Hours Distribution")
|
| 1242 |
+
|
| 1243 |
+
with gr.TabItem("Staff Absence Handler"):
|
| 1244 |
+
with gr.Row():
|
| 1245 |
+
with gr.Column():
|
| 1246 |
+
gr.Markdown("### Handle Staff Absence")
|
| 1247 |
+
absent_staff = gr.Number(label="Staff ID to be absent", precision=0)
|
| 1248 |
+
absence_start = gr.Number(label="Start Day", precision=0)
|
| 1249 |
+
absence_end = gr.Number(label="End Day", precision=0)
|
| 1250 |
+
handle_absence_btn = gr.Button("Redistribute Shifts", variant="primary")
|
| 1251 |
+
|
| 1252 |
+
with gr.Column():
|
| 1253 |
+
absence_result = gr.TextArea(label="Redistribution Results", interactive=False)
|
| 1254 |
+
updated_schedule = gr.DataFrame(label="Updated Schedule")
|
| 1255 |
+
absence_gantt_chart = gr.Image(label="Absence Schedule Visualization", elem_id="absence_gantt_chart")
|
| 1256 |
+
|
| 1257 |
+
return demo
|
| 1258 |
+
|
| 1259 |
+
def handle_staff_absence(schedule_df, absent_staff_id, absence_dates, max_hours_per_staff, overtime_percent):
|
| 1260 |
+
"""
|
| 1261 |
+
Redistribute shifts of absent staff member to others, prioritizing staff with lowest monthly hours
|
| 1262 |
+
"""
|
| 1263 |
+
try:
|
| 1264 |
+
# Create a copy of the original schedule
|
| 1265 |
+
new_schedule = schedule_df.copy()
|
| 1266 |
+
|
| 1267 |
+
# Get shifts that need to be redistributed
|
| 1268 |
+
absent_shifts = new_schedule[
|
| 1269 |
+
(new_schedule['staff_id'] == absent_staff_id) &
|
| 1270 |
+
(new_schedule['day'].isin(absence_dates))
|
| 1271 |
+
]
|
| 1272 |
+
|
| 1273 |
+
if absent_shifts.empty:
|
| 1274 |
+
return "No shifts found for the specified staff member on given dates.", None, None
|
| 1275 |
+
|
| 1276 |
+
# Get available staff (excluding absent staff)
|
| 1277 |
+
available_staff = sorted(list(set(new_schedule['staff_id']) - {absent_staff_id}))
|
| 1278 |
+
|
| 1279 |
+
# Calculate current hours for each staff member
|
| 1280 |
+
current_hours = new_schedule.groupby('staff_id')['duration'].sum()
|
| 1281 |
+
|
| 1282 |
+
# Sort staff by current hours (ascending) to prioritize those with fewer hours
|
| 1283 |
+
staff_hours_sorted = current_hours.reindex(available_staff).sort_values()
|
| 1284 |
+
available_staff = staff_hours_sorted.index.tolist()
|
| 1285 |
+
|
| 1286 |
+
# Calculate remaining available hours for each staff
|
| 1287 |
+
max_allowed_hours = max_hours_per_staff * (1 + overtime_percent/100)
|
| 1288 |
+
available_hours = {
|
| 1289 |
+
staff_id: max_allowed_hours - current_hours.get(staff_id, 0)
|
| 1290 |
+
for staff_id in available_staff
|
| 1291 |
+
}
|
| 1292 |
+
|
| 1293 |
+
results = []
|
| 1294 |
+
unassigned_shifts = []
|
| 1295 |
+
|
| 1296 |
+
# Process each shift that needs to be redistributed
|
| 1297 |
+
for _, shift in absent_shifts.iterrows():
|
| 1298 |
+
# Find eligible staff for this shift, prioritizing those with fewer hours
|
| 1299 |
+
eligible_staff = []
|
| 1300 |
+
eligible_staff_hours = {}
|
| 1301 |
+
|
| 1302 |
+
for staff_id in available_staff:
|
| 1303 |
+
# Check if staff has enough remaining hours
|
| 1304 |
+
if available_hours[staff_id] >= shift['duration']:
|
| 1305 |
+
# Check if staff is not already working that day
|
| 1306 |
+
staff_shifts_that_day = new_schedule[
|
| 1307 |
+
(new_schedule['staff_id'] == staff_id) &
|
| 1308 |
+
(new_schedule['day'] == shift['day'])
|
| 1309 |
+
]
|
| 1310 |
+
|
| 1311 |
+
if staff_shifts_that_day.empty:
|
| 1312 |
+
# Check minimum rest period (11 hours)
|
| 1313 |
+
day_before = new_schedule[
|
| 1314 |
+
(new_schedule['staff_id'] == staff_id) &
|
| 1315 |
+
(new_schedule['day'] == shift['day'] - 1)
|
| 1316 |
+
]
|
| 1317 |
+
|
| 1318 |
+
day_after = new_schedule[
|
| 1319 |
+
(new_schedule['staff_id'] == staff_id) &
|
| 1320 |
+
(new_schedule['day'] == shift['day'] + 1)
|
| 1321 |
+
]
|
| 1322 |
+
|
| 1323 |
+
can_work = True
|
| 1324 |
+
if not day_before.empty:
|
| 1325 |
+
end_time_before = day_before.iloc[0]['end']
|
| 1326 |
+
if (shift['start'] + 24 - end_time_before) < 11:
|
| 1327 |
+
can_work = False
|
| 1328 |
+
|
| 1329 |
+
if not day_after.empty and can_work:
|
| 1330 |
+
start_time_after = day_after.iloc[0]['start']
|
| 1331 |
+
if (starttime_after + 24 - shift['end']) < 11:
|
| 1332 |
+
can_work = False
|
| 1333 |
+
|
| 1334 |
+
if can_work:
|
| 1335 |
+
eligible_staff.append(staff_id)
|
| 1336 |
+
eligible_staff_hours[staff_id] = current_hours.get(staff_id, 0)
|
| 1337 |
+
|
| 1338 |
+
if eligible_staff:
|
| 1339 |
+
# Sort eligible staff by current hours to prioritize those with fewer hours
|
| 1340 |
+
sorted_eligible = sorted(eligible_staff, key=lambda x: eligible_staff_hours[x])
|
| 1341 |
+
best_staff = sorted_eligible[0] # Select staff with lowest hours
|
| 1342 |
+
|
| 1343 |
+
# Update the schedule
|
| 1344 |
+
new_schedule.loc[shift.name, 'staff_id'] = best_staff
|
| 1345 |
+
|
| 1346 |
+
# Update available hours and current hours
|
| 1347 |
+
available_hours[best_staff] -= shift['duration']
|
| 1348 |
+
current_hours[best_staff] = current_hours.get(best_staff, 0) + shift['duration']
|
| 1349 |
+
|
| 1350 |
+
results.append(
|
| 1351 |
+
f"Shift on Day {shift['day']} ({shift['duration']} hours) "
|
| 1352 |
+
f"reassigned to Staff {best_staff} (current hours: {current_hours[best_staff]:.1f})"
|
| 1353 |
+
)
|
| 1354 |
+
else:
|
| 1355 |
+
unassigned_shifts.append(
|
| 1356 |
+
f"Could not reassign shift on Day {shift['day']} ({shift['duration']} hours)"
|
| 1357 |
+
)
|
| 1358 |
+
|
| 1359 |
+
# Generate detailed summary with hours distribution
|
| 1360 |
+
summary = "\n".join([
|
| 1361 |
+
"Shift Redistribution Summary:",
|
| 1362 |
+
"----------------------------",
|
| 1363 |
+
f"Staff {absent_staff_id} absent for {len(absence_dates)} days",
|
| 1364 |
+
f"Successfully reassigned: {len(results)} shifts",
|
| 1365 |
+
f"Failed to reassign: {len(unassigned_shifts)} shifts",
|
| 1366 |
+
"\nCurrent Hours Distribution:",
|
| 1367 |
+
"-------------------------"
|
| 1368 |
+
] + [
|
| 1369 |
+
f"Staff {s}: {current_hours.get(s, 0):.1f} hours (of max {max_allowed_hours:.1f})"
|
| 1370 |
+
for s in sorted(available_staff)
|
| 1371 |
+
] + [
|
| 1372 |
+
"\nReassignment Details:",
|
| 1373 |
+
*results,
|
| 1374 |
+
"\nUnassigned Shifts:",
|
| 1375 |
+
*unassigned_shifts
|
| 1376 |
+
])
|
| 1377 |
+
|
| 1378 |
+
# Filter the schedule for the absence period
|
| 1379 |
+
absence_schedule = new_schedule[new_schedule['day'].isin(absence_dates)].copy()
|
| 1380 |
+
|
| 1381 |
+
# Create a Gantt chart for the absence period
|
| 1382 |
+
absence_gantt_path = create_gantt_chart(absence_schedule, len(absence_dates), len(set(absence_schedule['staff_id'])))
|
| 1383 |
+
|
| 1384 |
+
if unassigned_shifts:
|
| 1385 |
+
return summary, None, None
|
| 1386 |
+
else:
|
| 1387 |
+
return summary, absence_schedule, absence_gantt_path
|
| 1388 |
+
|
| 1389 |
+
except Exception as e:
|
| 1390 |
+
return f"Error redistributing shifts: {str(e)}", None, None
|
| 1391 |
+
|
| 1392 |
+
class FastScheduler:
|
| 1393 |
+
def __init__(self, num_staff, num_days, possible_shifts, staff_requirements, constraints):
|
| 1394 |
+
self.num_staff = num_staff
|
| 1395 |
+
self.num_days = num_days
|
| 1396 |
+
self.possible_shifts = possible_shifts
|
| 1397 |
+
self.staff_requirements = staff_requirements
|
| 1398 |
+
self.constraints = constraints
|
| 1399 |
+
self.best_schedule = None
|
| 1400 |
+
self.best_score = float('inf')
|
| 1401 |
+
# Pre-compute shift lookups for faster access
|
| 1402 |
+
self.shift_lookup = {shift['id']: shift for shift in possible_shifts}
|
| 1403 |
+
self.cycle_shifts = self._precompute_cycle_shifts()
|
| 1404 |
+
# Track staff state
|
| 1405 |
+
self.staff_sequences = {}
|
| 1406 |
+
self.staff_hours = {}
|
| 1407 |
+
self.max_monthly_hours = constraints['max_hours_per_staff']
|
| 1408 |
+
|
| 1409 |
+
def _precompute_cycle_shifts(self):
|
| 1410 |
+
"""Pre-compute which shifts can cover each cycle"""
|
| 1411 |
+
cycle_shifts = {}
|
| 1412 |
+
for cycle in self.staff_requirements[0].keys():
|
| 1413 |
+
cycle_shifts[cycle] = [shift for shift in self.possible_shifts if cycle in shift['cycles_covered']]
|
| 1414 |
+
return cycle_shifts
|
| 1415 |
+
|
| 1416 |
+
def optimize(self, time_limit=300):
|
| 1417 |
+
"""Main optimization method"""
|
| 1418 |
+
start_time = time.time()
|
| 1419 |
+
schedule = []
|
| 1420 |
+
|
| 1421 |
+
# Process each day
|
| 1422 |
+
for day in range(1, self.num_days + 1):
|
| 1423 |
+
# Get requirements for this day
|
| 1424 |
+
day_requirements = self.staff_requirements[day-1]
|
| 1425 |
+
|
| 1426 |
+
# Process each cycle
|
| 1427 |
+
for cycle, staff_needed in day_requirements.items():
|
| 1428 |
+
staff_assigned = 0
|
| 1429 |
+
|
| 1430 |
+
# Try each staff member until we meet the requirement
|
| 1431 |
+
for staff_id in range(1, self.num_staff + 1):
|
| 1432 |
+
# Check if we've met the requirement
|
| 1433 |
+
if staff_assigned >= staff_needed:
|
| 1434 |
+
break
|
| 1435 |
+
|
| 1436 |
+
# Check if we're out of time
|
| 1437 |
+
if time.time() - start_time > time_limit:
|
| 1438 |
+
return None
|
| 1439 |
+
|
| 1440 |
+
# Try to assign a shift
|
| 1441 |
+
shift = self._find_optimal_shift(staff_id, day, cycle, self.staff_hours)
|
| 1442 |
+
if shift:
|
| 1443 |
+
schedule.append(shift)
|
| 1444 |
+
staff_assigned += 1
|
| 1445 |
+
|
| 1446 |
+
# Validate the schedule after each day
|
| 1447 |
+
score = self._evaluate_schedule(schedule)
|
| 1448 |
+
if score == float('inf'):
|
| 1449 |
+
return None
|
| 1450 |
+
|
| 1451 |
+
# Final validation
|
| 1452 |
+
final_score = self._evaluate_schedule(schedule)
|
| 1453 |
+
if final_score == float('inf'):
|
| 1454 |
+
return None
|
| 1455 |
+
|
| 1456 |
+
return schedule
|
| 1457 |
+
|
| 1458 |
+
def _find_optimal_shift(self, staff_id, day, cycle, staff_hours):
|
| 1459 |
+
"""Optimized shift finding with early exits and pre-computed lookups"""
|
| 1460 |
+
# Quick access to staff's current state
|
| 1461 |
+
staff_info = self.staff_sequences.get(staff_id)
|
| 1462 |
+
current_hours = self.staff_hours.get(staff_id, 0)
|
| 1463 |
+
|
| 1464 |
+
# Early exit if staff has reached maximum hours
|
| 1465 |
+
if current_hours >= self.max_monthly_hours:
|
| 1466 |
+
return None
|
| 1467 |
+
|
| 1468 |
+
# Use pre-computed valid shifts for this cycle
|
| 1469 |
+
valid_shifts = self.cycle_shifts.get(cycle, [])
|
| 1470 |
+
if not valid_shifts:
|
| 1471 |
+
return None
|
| 1472 |
+
|
| 1473 |
+
# Quick consecutive days check
|
| 1474 |
+
if staff_info and staff_info.get('consecutive_days', 0) >= 6 and day - staff_info['last_day'] == 1:
|
| 1475 |
+
return None
|
| 1476 |
+
|
| 1477 |
+
# Filter shifts based on timing consistency (highest priority)
|
| 1478 |
+
if staff_info and day - staff_info['last_day'] == 1:
|
| 1479 |
+
required_start = staff_info['last_time']
|
| 1480 |
+
valid_shifts = [s for s in valid_shifts if s['start'] == required_start]
|
| 1481 |
+
if not valid_shifts:
|
| 1482 |
+
return None
|
| 1483 |
+
|
| 1484 |
+
# Quick hours check
|
| 1485 |
+
valid_shifts = [s for s in valid_shifts if current_hours + s['duration'] <= self.max_monthly_hours]
|
| 1486 |
+
if not valid_shifts:
|
| 1487 |
+
return None
|
| 1488 |
+
|
| 1489 |
+
# Check if staff already has a shift this day
|
| 1490 |
+
if any(s['staff_id'] == staff_id and s['day'] == day for s in self.best_schedule or []):
|
| 1491 |
+
return None
|
| 1492 |
+
|
| 1493 |
+
# Select first valid shift (optimization over finding "best" shift)
|
| 1494 |
+
shift = valid_shifts[0]
|
| 1495 |
+
assigned_shift = {
|
| 1496 |
+
'staff_id': staff_id,
|
| 1497 |
+
'day': day,
|
| 1498 |
+
'shift_id': shift['id'],
|
| 1499 |
+
'start': shift['start'],
|
| 1500 |
+
'end': shift['end'],
|
| 1501 |
+
'duration': shift['duration'],
|
| 1502 |
+
'cycles_covered': list(shift['cycles_covered'])
|
| 1503 |
+
}
|
| 1504 |
+
|
| 1505 |
+
# Update staff tracking
|
| 1506 |
+
consecutive_days = 1 if not staff_info else (
|
| 1507 |
+
staff_info['consecutive_days'] + 1 if day - staff_info['last_day'] == 1 else 1
|
| 1508 |
+
)
|
| 1509 |
+
|
| 1510 |
+
self.staff_sequences[staff_id] = {
|
| 1511 |
+
'last_day': day,
|
| 1512 |
+
'last_time': shift['start'],
|
| 1513 |
+
'consecutive_days': consecutive_days
|
| 1514 |
+
}
|
| 1515 |
+
self.staff_hours[staff_id] = current_hours + shift['duration']
|
| 1516 |
+
|
| 1517 |
+
return assigned_shift
|
| 1518 |
+
|
| 1519 |
+
def _evaluate_schedule(self, schedule):
|
| 1520 |
+
"""Optimized schedule evaluation with early exits"""
|
| 1521 |
+
if not schedule:
|
| 1522 |
+
return float('inf')
|
| 1523 |
+
|
| 1524 |
+
# Pre-compute staff shifts dictionary
|
| 1525 |
+
staff_shifts = {}
|
| 1526 |
+
for shift in schedule:
|
| 1527 |
+
staff_id = shift['staff_id']
|
| 1528 |
+
if staff_id not in staff_shifts:
|
| 1529 |
+
staff_shifts[staff_id] = []
|
| 1530 |
+
staff_shifts[staff_id].append(shift)
|
| 1531 |
+
|
| 1532 |
+
# Early exit on hours violation
|
| 1533 |
+
if self.staff_hours.get(staff_id, 0) > self.max_monthly_hours:
|
| 1534 |
+
return float('inf')
|
| 1535 |
+
|
| 1536 |
+
# Quick timing consistency check with early exit
|
| 1537 |
+
for shifts in staff_shifts.values():
|
| 1538 |
+
shifts.sort(key=lambda x: x['day'])
|
| 1539 |
+
for i in range(1, len(shifts)):
|
| 1540 |
+
if (shifts[i]['day'] - shifts[i-1]['day'] == 1 and
|
| 1541 |
+
shifts[i]['start'] != shifts[i-1]['start']):
|
| 1542 |
+
return float('inf')
|
| 1543 |
+
|
| 1544 |
+
# Coverage check with early exit
|
| 1545 |
+
coverage = self._check_coverage_requirements(schedule)
|
| 1546 |
+
if coverage > 0:
|
| 1547 |
+
return float('inf')
|
| 1548 |
+
|
| 1549 |
+
return 0 # Valid schedule found
|
| 1550 |
+
|
| 1551 |
+
def _check_coverage_requirements(self, schedule):
|
| 1552 |
+
"""Optimized coverage check using pre-computed data"""
|
| 1553 |
+
day_cycle_coverage = {}
|
| 1554 |
+
|
| 1555 |
+
# Pre-compute coverage needs
|
| 1556 |
+
for shift in schedule:
|
| 1557 |
+
day = shift['day']
|
| 1558 |
+
if day not in day_cycle_coverage:
|
| 1559 |
+
day_cycle_coverage[day] = {cycle: 0 for cycle in self.staff_requirements[0].keys()}
|
| 1560 |
+
|
| 1561 |
+
for cycle in shift['cycles_covered']:
|
| 1562 |
+
day_cycle_coverage[day][cycle] += 1
|
| 1563 |
+
|
| 1564 |
+
# Check coverage
|
| 1565 |
+
violations = 0
|
| 1566 |
+
for day in range(1, self.num_days + 1):
|
| 1567 |
+
if day not in day_cycle_coverage:
|
| 1568 |
+
return float('inf') # Missing day coverage
|
| 1569 |
+
|
| 1570 |
+
day_coverage = day_cycle_coverage[day]
|
| 1571 |
+
required = self.staff_requirements[day-1]
|
| 1572 |
+
|
| 1573 |
+
for cycle, needed in required.items():
|
| 1574 |
+
if day_coverage[cycle] < needed:
|
| 1575 |
+
violations += needed - day_coverage[cycle]
|
| 1576 |
+
if violations > 0: # Early exit on any violation
|
| 1577 |
+
return violations
|
| 1578 |
+
|
| 1579 |
+
return violations
|
| 1580 |
+
|
| 1581 |
+
def reset(self):
|
| 1582 |
+
"""Reset the scheduler state"""
|
| 1583 |
+
self.best_schedule = None
|
| 1584 |
+
self.best_score = float('inf')
|
| 1585 |
+
self.staff_sequences = {}
|
| 1586 |
+
self.staff_hours = {}
|