Skip to content

Commit 92deaf7

Browse files
thejus03leslieyip02ravern
authored
fix(optimiser): account for non-overlapping same-timeslot courses (#4101)
* feat(optimiser): enhance module slot structure with weeks support * Added Weeks field to ModuleSlot for better week management. * Introduced WeeksSet and WeeksString for efficient week handling in module timetable processing. * Updated merge and filter logic to incorporate weeks in combination keys and conflict checks. * refactor(optimiser): update Weeks field type and handling in module slots * Changed Weeks field in ModuleSlot from []int to any for flexible week representation. * Updated parsing logic to handle weeks as any type, ensuring compatibility with existing functionality. * Enhanced conflict checking to skip week validation for non-[]int types. * removed debug prints --------- Co-authored-by: Leslie Yip <[email protected]> Co-authored-by: Ravern Koh <[email protected]>
1 parent d96e515 commit 92deaf7

File tree

3 files changed

+76
-26
lines changed

3 files changed

+76
-26
lines changed

website/api/optimiser/_models/models.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,15 @@ type ModuleSlot struct {
3434
StartTime string `json:"startTime"`
3535
Venue string `json:"venue"`
3636
Coordinates Coordinates `json:"coordinates"`
37+
Weeks any `json:"weeks"`
3738

3839
// Parsed fields
39-
StartMin int // Minutes from 00:00 (e.g., 540 for 09:00)
40-
EndMin int // Minutes from 00:00
41-
DayIndex int // 0=Monday, 1=Tuesday, 2=Wednesday, 3=Thursday, 4=Friday, 5=Saturday
42-
LessonKey string // "MODULE|LessonType"
40+
StartMin int // Minutes from 00:00 (e.g., 540 for 09:00)
41+
EndMin int // Minutes from 00:00
42+
DayIndex int // 0=Monday, 1=Tuesday, 2=Wednesday, 3=Thursday, 4=Friday, 5=Saturday
43+
LessonKey string // "MODULE|LessonType"
44+
WeeksSet map[int]bool
45+
WeeksString string
4346
}
4447

4548
// ParseModuleSlotFields parses and populates the parsed fields in ModuleSlot for faster computation

website/api/optimiser/_modules/modules.go

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ package modules
22

33
import (
44
"encoding/json"
5+
"strconv"
56
"strings"
67

7-
"github.com/nusmodifications/nusmods/website/api/optimiser/_client"
8-
"github.com/nusmodifications/nusmods/website/api/optimiser/_constants"
9-
"github.com/nusmodifications/nusmods/website/api/optimiser/_models"
8+
client "github.com/nusmodifications/nusmods/website/api/optimiser/_client"
9+
constants "github.com/nusmodifications/nusmods/website/api/optimiser/_constants"
10+
models "github.com/nusmodifications/nusmods/website/api/optimiser/_models"
1011
)
1112

1213
/*
@@ -47,6 +48,27 @@ func GetAllModuleSlots(optimiserRequest models.OptimiserRequest) (map[string]map
4748
}
4849
}
4950

51+
// Parse the weeks
52+
for i := range moduleTimetable {
53+
54+
// Note: if weeks is not a []int, then skip parsing
55+
// Currently we are not handling week conflict for non-[]int weeks
56+
if _, ok := moduleTimetable[i].Weeks.([]any); !ok {
57+
continue
58+
}
59+
60+
moduleTimetable[i].WeeksSet = make(map[int]bool)
61+
weeks := moduleTimetable[i].Weeks.([]any)
62+
weeksStrings := make([]string, len(weeks))
63+
64+
for j, week := range weeks {
65+
weekInt := int(week.(float64))
66+
moduleTimetable[i].WeeksSet[weekInt] = true
67+
weeksStrings[j] = strconv.Itoa(weekInt)
68+
}
69+
moduleTimetable[i].WeeksString = strings.Join(weeksStrings, ",")
70+
}
71+
5072
// Store the module slots for the module
5173
moduleSlots[module] = mergeAndFilterModuleSlots(moduleTimetable, venues, optimiserRequest, module)
5274

@@ -77,7 +99,8 @@ func mergeAndFilterModuleSlots(timetable []models.ModuleSlot, venues map[string]
7799
*/
78100

79101
classGroups := make(map[string][]models.ModuleSlot)
80-
for _, slot := range timetable {
102+
for i := range timetable {
103+
slot := &timetable[i]
81104
// Skip venues without location data, except E-Venues (virtual venue)
82105
if !constants.E_Venues[slot.Venue] {
83106
venueLocation := venues[slot.Venue].Location
@@ -90,7 +113,7 @@ func mergeAndFilterModuleSlots(timetable []models.ModuleSlot, venues map[string]
90113
slot.Coordinates = venues[slot.Venue].Location
91114

92115
groupKey := slot.LessonType + "|" + slot.ClassNo
93-
classGroups[groupKey] = append(classGroups[groupKey], slot)
116+
classGroups[groupKey] = append(classGroups[groupKey], *slot)
94117
}
95118

96119
/*
@@ -107,14 +130,15 @@ func mergeAndFilterModuleSlots(timetable []models.ModuleSlot, venues map[string]
107130

108131
// Only apply filters to physical lessons
109132
if !isRecorded {
110-
for _, slot := range slots {
133+
for i := range slots {
134+
slot := &slots[i]
111135
// Check free days
112136
if freeDaysMap[slot.Day] {
113137
allValid = false
114138
break
115139
}
116140

117-
if isSlotOutsideTimeRange(slot, earliestMin, latestMin) {
141+
if isSlotOutsideTimeRange(*slot, earliestMin, latestMin) {
118142
allValid = false
119143
break
120144
}
@@ -128,7 +152,7 @@ func mergeAndFilterModuleSlots(timetable []models.ModuleSlot, venues map[string]
128152
}
129153

130154
/*
131-
Now merge all slots of the same lessonType, slot, startTime and building
155+
Now merge all slots of the same lessonType, slot, startTime, weeks and building
132156
We are doing this to avoid unnecessary calculations & reduce search space
133157
*/
134158

@@ -137,12 +161,11 @@ func mergeAndFilterModuleSlots(timetable []models.ModuleSlot, venues map[string]
137161

138162
for _, slots := range validClassGroups {
139163
for _, slot := range slots {
140-
lessonKey := module + " " + slot.LessonType
141-
isRecorded := recordingsMap[lessonKey]
142164

143-
if !isRecorded && !constants.E_Venues[slot.Venue] {
165+
if !constants.E_Venues[slot.Venue] {
144166
buildingName := extractBuildingName(slot.Venue)
145-
combinationKey := slot.LessonType + "|" + slot.Day + "|" + slot.StartTime + "|" + buildingName
167+
168+
combinationKey := slot.LessonType + "|" + slot.Day + "|" + slot.StartTime + "|" + buildingName + "|" + slot.WeeksString
146169

147170
if seenCombinations[combinationKey] {
148171
continue

website/api/optimiser/_solver/solver.go

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import (
66
"sort"
77
"strings"
88

9-
"github.com/nusmodifications/nusmods/website/api/optimiser/_constants"
10-
"github.com/nusmodifications/nusmods/website/api/optimiser/_models"
11-
"github.com/nusmodifications/nusmods/website/api/optimiser/_modules"
9+
constants "github.com/nusmodifications/nusmods/website/api/optimiser/_constants"
10+
models "github.com/nusmodifications/nusmods/website/api/optimiser/_models"
11+
modules "github.com/nusmodifications/nusmods/website/api/optimiser/_modules"
1212
"github.com/umahmood/haversine"
1313
)
1414

@@ -48,14 +48,20 @@ func BeamSearch(
4848
slotGroups := lessonToSlots[lessonKey]
4949
limit := min(len(slotGroups), branchingFactor)
5050

51+
// iterate over all partial timetables in the beam
5152
for _, state := range beam {
53+
54+
// iterate over all slot groups for the current lesson
5255
for i := 0; i < limit; i++ {
5356
group := slotGroups[i]
54-
57+
58+
// Filters out invalid slots by checking if
59+
// DayIndex is not -1 which marks invalid slots when parsing in ParseModuleSlotFields func
5560
validGroup := make([]models.ModuleSlot, 0, len(group))
56-
for _, slot := range group {
61+
for i := range group {
62+
slot := &group[i]
5763
if slot.DayIndex >= 0 && slot.DayIndex < 6 {
58-
validGroup = append(validGroup, slot)
64+
validGroup = append(validGroup, *slot)
5965
}
6066
}
6167

@@ -81,8 +87,10 @@ func BeamSearch(
8187
}
8288
}
8389

90+
// if no valid partial timetables found then skip to next lesson
91+
// by keeping the beam to create partial timetables
8492
if len(nextBeam) == 0 {
85-
return models.TimetableState{}
93+
continue
8694
}
8795

8896
sort.Slice(nextBeam, func(i, j int) bool {
@@ -164,7 +172,22 @@ func hasConflict(state models.TimetableState, newSlots []models.ModuleSlot) bool
164172
for _, oldSlot := range state.DaySlots[newSlot.DayIndex] {
165173
// Check if slots overlap in time
166174
if newSlot.StartMin < oldSlot.EndMin && oldSlot.StartMin < newSlot.EndMin {
167-
return true
175+
176+
// if weeks is not a []int, then skip checking for week conflict
177+
if _, ok := newSlot.Weeks.([]any); !ok {
178+
return true
179+
}
180+
if _, ok := oldSlot.Weeks.([]any); !ok {
181+
return true
182+
}
183+
184+
// check if the weeks overlap
185+
for _, week := range newSlot.Weeks.([]any) {
186+
weekInt := int(week.(float64))
187+
if oldSlot.WeeksSet[weekInt] {
188+
return true
189+
}
190+
}
168191
}
169192
}
170193
}
@@ -204,9 +227,10 @@ func getPhysicalSlots(daySlots []models.ModuleSlot, recordings map[string]bool)
204227
}
205228

206229
physicalSlots := make([]models.ModuleSlot, 0, len(daySlots))
207-
for _, slot := range daySlots {
230+
for i := range daySlots {
231+
slot := &daySlots[i]
208232
if !isLessonRecorded(slot.LessonKey, recordings) {
209-
physicalSlots = append(physicalSlots, slot)
233+
physicalSlots = append(physicalSlots, *slot)
210234
}
211235
}
212236

0 commit comments

Comments
 (0)