Skip to content

Commit 4035237

Browse files
authored
Documentation and Code Cleanup (#12)
* Documentation and Code Cleanup Documented soft and hard constraint logic and touch up sequence diagram. Clean up dead code logic and historical comments. * Soft/Hard Ban minor tweak AM to PM changed from soft to hard ban
1 parent 57554c8 commit 4035237

File tree

7 files changed

+152
-128
lines changed

7 files changed

+152
-128
lines changed

README.md

Lines changed: 75 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,76 @@ A Streamlit-based application for scheduling staff duties. This tool provides an
1818
* **Excel Export:** Download the final roster and statistics as an Excel file.
1919
* **Secure Client-Side Storage:** Configurations are saved to your local machine as JSON, ensuring no personal data is stored on the server.
2020

21+
## Logic & Constraints
22+
23+
The solver balances two types of rules to create a schedule. It also strictly enforces specific transition rules between shifts on consecutive days.
24+
25+
### 1. Hard Constraints (Mandatory)
26+
These rules **must** be met. If they cannot be satisfied, the solver will return "No Solution."
27+
* **Coverage:** Every day must have the exact number of required staff (e.g., 1 AM, 1 PM).
28+
* **Fixed Assignments:** Any manual entry in the grid (e.g., a user manually assigned 'AM') is treated as locked.
29+
* **Availability:** Staff marked as 'X' (Unavailable) cannot be assigned duties.
30+
* **Physiological Limits:**
31+
* Max 1 shift per person per day.
32+
* Specific "Hard Ban" transitions (see table below) are forbidden.
33+
34+
### 2. Soft Constraints (Optimization Targets)
35+
These are rules the solver *tries* to follow but can break if necessary to find a solution. Breaking them incurs a "penalty."
36+
* **Fairness:** The solver aims to minimize the difference in total points between the busiest and least busy staff member.
37+
* **Soft Bans:** Specific "Soft Ban" transitions (see table below) are discouraged and penalized but allowed if no other option exists.
38+
39+
### 3. Shift Transition Permutations (Day N $\rightarrow$ Day N+1)
40+
The following table defines exactly which shift transitions are allowed, penalized (Soft Ban), or forbidden (Hard Ban).
41+
42+
| Current Shift (Day N) | Next Shift (Day N+1) | Status | Notes |
43+
| :--- | :--- | :--- | :--- |
44+
| **AM** | **AM** | ✅ Allowed | |
45+
| **AM** | **PM** | ⚠️ Soft Ban | Double shift split across days. |
46+
| **AM** | **24H** | ✅ Allowed | |
47+
| **AM** | **S/B** | ✅ Allowed | |
48+
| **PM** | **AM** | ⛔ Hard Ban | Insufficient rest (<12h). |
49+
| **PM** | **PM** | ⛔ Hard Ban | |
50+
| **PM** | **24H** | ⛔ Hard Ban | Insufficient rest before 24H. |
51+
| **PM** | **S/B** | ⚠️ Soft Ban | |
52+
| **24H** | **AM** | ⛔ Hard Ban | Insufficient rest after 24H. |
53+
| **24H** | **PM** | ✅ Allowed | |
54+
| **24H** | **24H** | ⛔ Hard Ban | No consecutive 24H shifts. |
55+
| **24H** | **S/B** | ⛔ Hard Ban | |
56+
| **S/B** | **AM** | ⚠️ Soft Ban | |
57+
| **S/B** | **PM** | ✅ Allowed | |
58+
| **S/B** | **24H** | ⛔ Hard Ban | |
59+
| **S/B** | **S/B** | ⚠️ Soft Ban | Consecutive standby discouraged. |
60+
| **Empty** | **Any** | ✅ Allowed | |
61+
| **Any** | **Empty** | ✅ Allowed | |
62+
| **Empty** | **Empty** | ✅ Allowed | |
63+
2164
## Architecture
2265

2366
The application follows a Model-View-Controller (MVC) pattern adapted for Streamlit.
2467

2568
```mermaid
2669
sequenceDiagram
70+
autonumber
2771
actor User
28-
participant Browser as Streamlit UI
29-
participant Sidebar as Sidebar
30-
participant Logic as Logic Layer
31-
participant Solver as DutySchedulerEngine
32-
participant DataMgr as DataManager
33-
34-
User->>Browser: open app / choose Planner or Settings
35-
Browser->>Sidebar: Load Default Template (config.json)
36-
Browser->>Sidebar: Upload/Download Config JSON (Client Side)
37-
Browser->>Logic: generate_empty_schedule(year, month, personnel)
38-
Browser->>Logic: prepare_solver_request(year, month, roster, days, config)
39-
Logic->>Solver: build_model(SolverRequest)
40-
Logic->>Solver: solve()
41-
Solver-->>Logic: solution or failure
42-
Logic->>DataMgr: load_previous_balance / load_constraints
43-
Logic-->>Browser: roster_df, stats, excel_bytes (for download)
44-
Browser-->>User: display roster, stats, download link
72+
participant UI as Streamlit Interface
73+
participant Controller as Logic & Validation
74+
participant Solver as Scheduling Engine
75+
participant Data as Data Manager
76+
77+
User->>UI: Inputs Data / Configures Rules
78+
UI->>Controller: Request Schedule Optimization
79+
80+
rect rgb(240, 248, 255)
81+
Note over Controller, Solver: Core Logic
82+
Controller->>Data: Fetch Holidays & Previous Balance
83+
Controller->>Solver: Build Mathematical Model
84+
Solver->>Solver: Apply Hard Constraints & Transitions
85+
Solver->>Solver: Minimize Soft Constraint Penalties
86+
end
87+
88+
Solver-->>Controller: Return Optimal Schedule
89+
Controller-->>UI: Display Roster & Stats
90+
UI-->>User: Download Excel / JSON
4591
```
4692

4793
## Setup & Installation
@@ -68,42 +114,20 @@ sequenceDiagram
68114
streamlit run streamlit_app.py
69115
```
70116

71-
## Configuration
72-
73-
The application loads a **default template** from `config.json` on startup.
74-
To save your specific personnel and rules:
75-
1. Go to the **sidebar**.
76-
2. Click **Download Config JSON**.
77-
3. Next time you use the app, upload this file to restore your settings.
78-
79-
**Key Settings:**
80-
* **Personnel:** List of names.
81-
* **Constraints:**
82-
* `personnel_needed_per_shift`: Dictionary defining needs (e.g., `{"AM": 1, "PM": 1}`).
83-
* `max_consecutive_duties`: Max days a person can work in a row.
84-
* **Points:**
85-
* **Base Points:** How many points is a duty worth? (e.g., `24H = 2.0`, `AM = 1.0`).
86-
* **Multipliers:** Configure multipliers for Weekends, Public Holidays, etc.
87-
88-
## Development
89-
90-
* **Dependency Management:** Uses `pip-tools`.
91-
* To update deps: `pip-compile requirements.in`
92-
* To install dev deps: `pip install -r requirements-dev.txt`
93-
* **Linting:** Uses `ruff` for linting and formatting.
94-
* Check: `ruff check .`
95-
* Format: `ruff format .`
96-
* **Tests:** Run `pytest` to execute the test suite.
97-
* Run with coverage: `pytest --cov=app tests/`
98-
99117
## Testing Methodology
100118

101-
The project employs a robust testing strategy:
102-
* **Unit Tests (`tests/test_logic.py`, `tests/test_data.py`):** Verify individual components in isolation, mocking external dependencies like file I/O.
103-
* **Core Logic Tests (`tests/test_core_scheduler.py`):** Validate the solver engine against specific constraints.
104-
* **Integration Tests (`tests/test_app_integration.py`):** Use Streamlit's `AppTest` framework to simulate user interactions and verify UI state persistence.
119+
The project employs a comprehensive testing strategy:
120+
121+
* **Unit Tests (`tests/test_logic.py`, `tests/test_data.py`):**
122+
* *Methodology:* **Mocking & Isolation**. External dependencies (like File I/O) are mocked to test data parsing logic purely.
123+
* *Focus:* Input validation, data transformation, and correct handling of edge cases (e.g., invalid dates).
124+
* **Core Logic Tests (`tests/test_core_scheduler.py`):**
125+
* *Methodology:* **Constraint Verification**. Sets up specific minimal scenarios to prove that hard constraints (like "No consecutive 24H shifts") actually prevent invalid solutions.
126+
* *Focus:* Mathematical correctness of the OR-Tools model against the Transition Permutations defined above.
127+
* **Integration Tests (`tests/test_app_integration.py`):**
128+
* *Methodology:* **Headless UI Testing**. Uses `streamlit.testing` to simulate a user clicking buttons and changing settings to ensure the state updates correctly across the app.
105129

106130
## Deployment
107131

108-
The project is live at
109-
[smart-duty-scheduler.streamlit.app/](https://smart-duty-scheduler.streamlit.app/)
132+
Try out the live demo at:
133+
[**smart-duty-scheduler.streamlit.app**](https://smart-duty-scheduler.streamlit.app/)

app/core/data.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,6 @@ def load_config(filepath: str = C.CONFIG_FILE) -> AppConfig:
5555
logger.error(f"Unexpected error loading config: {e}. Using defaults.")
5656
return AppConfig.default()
5757

58-
# REMOVED: save_config method to prevent server-side data leaks.
59-
6058
@staticmethod
6159
def load_previous_balance(excel_file: Union[str, Any]) -> Dict[str, float]:
6260
"""
@@ -71,7 +69,6 @@ def load_previous_balance(excel_file: Union[str, Any]) -> Dict[str, float]:
7169
7270
Raises:
7371
ValueError: If required columns ('Name', 'Carry Over') are missing.
74-
Exception: Re-raises other parsing errors after logging.
7572
"""
7673
if not excel_file:
7774
return {}

app/core/scheduler.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ def _apply_shift_logic(self):
191191
# Forbidden pairs: (Day D Shift, Day D+1 Shift) -> Strictly prevented
192192
forbidden_transitions = [
193193
("PM", "PM"),
194+
("PM", "AM"),
194195
("PM", "24H"),
195196
("24H", "AM"),
196197
("24H", "24H"),
@@ -201,10 +202,9 @@ def _apply_shift_logic(self):
201202
# Soft Ban pairs: (Day D Shift, Day D+1 Shift) -> Discouraged via penalty
202203
soft_ban_transitions = [
203204
("AM", "PM"),
204-
("PM", "AM"),
205-
("PM", "S/B"), # Fixed: SB -> S/B
206-
("S/B", "AM"), # Fixed: SB -> S/B
207-
("S/B", "S/B"), # Fixed: SB -> S/B
205+
("PM", "S/B"),
206+
("S/B", "AM"),
207+
("S/B", "S/B"),
208208
]
209209

210210
for person in self.req.staff_ids:
@@ -231,7 +231,6 @@ def _apply_shift_logic(self):
231231
violation_var = self.model.NewBoolVar(f"soft_ban_{person}_{day}_{prev_shift}_{next_shift}")
232232

233233
# violation_var <=> (Prev AND Next)
234-
# This utility function forces violation_var to 1 if both conditions are met, else 0
235234
self.model.AddMultiplicationEquality(
236235
violation_var,
237236
[self.vars[(person, day, prev_shift)], self.vars[(person, next_day, next_shift)]],
@@ -246,7 +245,6 @@ def _apply_fairness_objective(self):
246245
person_points = []
247246
SCALE = 100
248247

249-
# High penalty to discourage soft bans (equivalent to 50 points difference)
250248
# Rationale: This value is chosen to be significantly higher than normal point variations
251249
# to effectively act as a soft constraint, while still allowing the solver to violate it
252250
# if no other solution exists (unlike a hard constraint).
@@ -287,7 +285,6 @@ def _apply_fairness_objective(self):
287285
self.model.Add(total_penalty == 0)
288286

289287
# Minimize Fairness Gap + Penalties
290-
# This ensures stats remain accurate (based on person_points), but solver choice is influenced by penalty
291288
self.model.Minimize((max_pts - min_pts) + total_penalty)
292289

293290
def solve(self) -> Optional[Tuple[Dict[Tuple[str, int], str], Any]]:

app/logic.py

Lines changed: 40 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
app/logic.py
33
44
This module serves as the 'Controller' in the MVC pattern.
5-
It bridges the Gap between the Streamlit UI (View) and the Data/Scheduler (Model).
5+
It bridges the gap between the Streamlit UI (View) and the Data/Scheduler (Model).
66
It handles data transformation, safe parsing, and orchestrating the solving process.
77
"""
88

@@ -20,12 +20,14 @@
2020
from app.core.scheduler import DutySchedulerEngine, SolverRequest
2121
from app.models.config import AppConfig
2222

23-
# Setup logger for this module
2423
logger = logging.getLogger(__name__)
2524

2625

2726
def get_day_num(col_name: str) -> int:
28-
"""Safely extracts the day integer from a column string."""
27+
"""
28+
Safely extracts the day integer from a column string (e.g., 'D1' -> 1).
29+
Returns 0 if the format does not match.
30+
"""
2931
match = re.match(r"^D(\d+)$", str(col_name))
3032
if match:
3133
return int(match.group(1))
@@ -35,7 +37,7 @@ def get_day_num(col_name: str) -> int:
3537
def get_holidays(year: int, country_code: str = "SG") -> holidays.HolidayBase:
3638
"""
3739
Returns the holiday object for the given country and year.
38-
Defaults to Singapore (SG) if code is invalid or not found.
40+
Defaults to Singapore (SG) if the provided code is invalid.
3941
"""
4042
try:
4143
return holidays.country_holidays(country_code, years=year)
@@ -47,7 +49,18 @@ def get_holidays(year: int, country_code: str = "SG") -> holidays.HolidayBase:
4749
def generate_empty_schedule(
4850
year: int, month: int, personnel: List[str], country_code: str = "SG"
4951
) -> Tuple[pd.DataFrame, pd.DataFrame]:
50-
"""Creates the initial empty DataFrames for the Roster and Day Configuration."""
52+
"""
53+
Creates the initial empty DataFrames for the Roster and Day Configuration.
54+
55+
Args:
56+
year (int): The selected year.
57+
month (int): The selected month.
58+
personnel (List[str]): List of staff names.
59+
country_code (str): Country code for holiday generation.
60+
61+
Returns:
62+
Tuple[pd.DataFrame, pd.DataFrame]: (Roster DataFrame, Day Config DataFrame).
63+
"""
5164
try:
5265
period = pd.Period(f"{year}-{month}")
5366
num_days = period.days_in_month
@@ -63,7 +76,6 @@ def generate_empty_schedule(
6376
try:
6477
dt = pd.Timestamp(year=year, month=month, day=d)
6578
except ValueError as e:
66-
# Raise error for invalid dates to prevent silent configuration issues
6779
raise ValueError(f"Invalid date generated: {year}-{month}-{d}") from e
6880

6981
is_ph = dt in country_holidays
@@ -89,17 +101,17 @@ def generate_empty_schedule(
89101
return df_roster, df_days
90102

91103

92-
def synchronize_roster_index(df_roster: Optional[pd.DataFrame], new_personnel: List[str]) -> Optional[pd.DataFrame]:
93-
"""Reindexes the roster DataFrame to match a new list of personnel."""
94-
if df_roster is None:
95-
return None
96-
# reindex handles new rows; fillna handles any existing NaN cells or new rows
97-
new_df = df_roster.reindex(index=new_personnel, fill_value="")
98-
return new_df.fillna("")
104+
def clear_schedule(df_roster: Optional[pd.DataFrame], clear_constraints: bool = False) -> Optional[pd.DataFrame]:
105+
"""
106+
Clears data from the roster grid.
99107
108+
Args:
109+
df_roster (pd.DataFrame): The current roster.
110+
clear_constraints (bool): If True, clears everything. If False, keeps 'X' (unavailable).
100111
101-
def clear_schedule(df_roster: Optional[pd.DataFrame], clear_constraints: bool = False) -> Optional[pd.DataFrame]:
102-
"""Clears data from the roster grid."""
112+
Returns:
113+
Optional[pd.DataFrame]: The cleared dataframe.
114+
"""
103115
if df_roster is None:
104116
return None
105117

@@ -136,14 +148,12 @@ def apply_imported_constraints(
136148
if df_roster is None or not imported_data:
137149
return df_roster
138150

139-
# Create a copy to avoid unintended mutation if used elsewhere
140151
df = df_roster.copy()
141152

142153
for name, day_map in imported_data.items():
143154
if name in df.index:
144155
for day_num, val in day_map.items():
145156
col_name = f"D{day_num}"
146-
# Ensure the column exists in the current month structure
147157
if col_name in df.columns:
148158
df.at[name, col_name] = val
149159

@@ -171,7 +181,6 @@ def prepare_solver_request(
171181
inactive_days.append(day_num)
172182
day_modes[day_num] = row["Mode"]
173183

174-
# Calculate exact weight for this day based on date/multipliers
175184
try:
176185
current_date = pd.Timestamp(year=year, month=month, day=day_num)
177186
for shift in ["AM", "PM", "24H", "S/B"]:
@@ -214,7 +223,12 @@ def run_solver(
214223
config: AppConfig,
215224
prev_balance: Dict[str, float],
216225
) -> Optional[Tuple[Dict[Tuple[str, int], str], int]]:
217-
"""Orchestrates the solving process."""
226+
"""
227+
Orchestrates the solving process.
228+
229+
Returns:
230+
Optional[Tuple[Dict, int]]: (Schedule Dictionary, Solver Status Code) or None on failure.
231+
"""
218232
try:
219233
req = prepare_solver_request(year, month, df_roster, df_days, config)
220234
engine = DutySchedulerEngine(config, prev_balance, req)
@@ -231,7 +245,10 @@ def run_solver(
231245
def calculate_stats(
232246
df_roster: pd.DataFrame, df_days: pd.DataFrame, config: AppConfig, prev_balance: Dict
233247
) -> pd.DataFrame:
234-
"""Calculates point statistics for the current roster state."""
248+
"""
249+
Calculates point statistics for the current roster state.
250+
Computes 'Month Pts' based on assignments and 'Carry Over' based on previous balance.
251+
"""
235252
summary = []
236253
raw_carry_overs = []
237254
country_holidays = get_holidays(config.year, config.country_code)
@@ -254,10 +271,7 @@ def calculate_stats(
254271
try:
255272
current_date = pd.Timestamp(year=config.year, month=config.month, day=day_idx)
256273
except Exception as e:
257-
logger.warning(
258-
f"Skipping invalid date for {person} on day {day_idx}: "
259-
f"year={config.year}, month={config.month}. Error: {e}"
260-
)
274+
logger.warning(f"Skipping invalid date for {person} on day {day_idx}: {e}")
261275
continue
262276

263277
scaled_pts = config.points.calculate_score(
@@ -275,16 +289,15 @@ def calculate_stats(
275289
min_carry = min(raw_carry_overs) if raw_carry_overs else 0.0
276290
final_stats = []
277291
for record in summary:
278-
# Explicitly set Carry Over to Raw Total so imported points are visible
279-
# Standard deviation in planner.py uses this column for fairness check.
292+
# Normalize Carry Over so the lowest person starts at 0 next month
280293
record["Carry Over"] = record["Raw Total"] - min_carry
281294
final_stats.append(record)
282295

283296
return pd.DataFrame(final_stats)
284297

285298

286299
def export_to_excel_bytes(df_roster: pd.DataFrame, df_stats: pd.DataFrame, config: AppConfig) -> bytes:
287-
"""Generates a downloadable Excel file."""
300+
"""Generates a downloadable Excel file (bytes)."""
288301
output = io.BytesIO()
289302
wb = Workbook()
290303
ws = wb.active
@@ -308,8 +321,6 @@ def export_to_excel_bytes(df_roster: pd.DataFrame, df_stats: pd.DataFrame, confi
308321
# Styles
309322
fill_header = PatternFill("solid", fgColor=C.COLOR_HEADER_BG.replace("#", ""))
310323
fill_x = PatternFill("solid", fgColor=C.COLOR_CONSTRAINT_BG.replace("#", ""))
311-
312-
# Use constants for colors
313324
fill_24h = PatternFill("solid", fgColor=C.COLOR_FILL_24H)
314325
fill_am = PatternFill("solid", fgColor=C.COLOR_FILL_AM)
315326
fill_pm = PatternFill("solid", fgColor=C.COLOR_FILL_PM)

0 commit comments

Comments
 (0)