Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 45 additions & 48 deletions backend/ranker.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,14 @@

class ScheduleRanker:
@staticmethod
def score_schedule(schedule, preferences):
def evaluate_schedule(schedule, preferences):
"""
Scores a schedule based on preferences.
Lower score is better? Or Higher? Let's say Higher is better.

Preferences:
- avoid_early_morning (bool): 1-2节
- avoid_weekend (bool): Sat/Sun
- compactness (str): "high" (bunch together), "low" (spread out), "none"
- max_daily_load (int): Penalty if > X
Evaluates a schedule based on preferences and returns score + breakdown.
"""
score = 100.0
base_score = 100.0
details = {}
total_penalty = 0.0
total_bonus = 0.0

# Merge bitmaps
full_bitmap = [0] * 30 # Assume max weeks 25
Expand All @@ -29,44 +25,42 @@ def score_schedule(schedule, preferences):
val = 0
full_bitmap[w] |= val

# 1. Avoid Early Morning (Nodes 1-2 -> Bits 0-1, 13-14, etc.)
# 1. Avoid Early Morning
if preferences.get('avoid_early_morning'):
penalty = 0
for w in range(1, 26): # Check typical weeks
for w in range(1, 26):
mask = full_bitmap[w]
if mask == 0: continue
# Early morning mask: Day 0..6, Nodes 0..1
# Bit pos = day*13 + node
early_mask = 0
for d in range(7):
early_mask |= (1 << (d * 13 + 0))
early_mask |= (1 << (d * 13 + 1))

if (mask & early_mask):
penalty += 1
score -= penalty * 2 # 严格惩罚早八

# 2. Avoid Weekend (Days 5, 6)
p_val = penalty * 2
total_penalty += p_val
details['早八回避'] = -p_val

# 2. Avoid Weekend
if preferences.get('avoid_weekend'):
penalty = 0
weekend_mask = 0
# Day 5 (Sat), Day 6 (Sun). All 13 nodes.
# Bits 65-77 (Sat), 78-90 (Sun) ... wait 5*13=65.
for node in range(13):
weekend_mask |= (1 << (5 * 13 + node))
weekend_mask |= (1 << (6 * 13 + node))

for w in range(1, 26):
if (full_bitmap[w] & weekend_mask):
penalty += 1
score -= penalty * 2.0 # Higher penalty for weekends

# 3. Compactness (Variance of start times? Or density?)
# Let's use daily variance.
# Compact -> High density, few gaps.
# Spread -> Evenly distributed.
p_val = penalty * 2.0
total_penalty += p_val
details['周末回避'] = -p_val

# 3. Compactness
if preferences.get('compactness') in ['high', 'low']:
# Calculate gaps
total_gaps = 0
for w in range(1, 26):
mask = full_bitmap[w]
Expand All @@ -75,10 +69,6 @@ def score_schedule(schedule, preferences):
day_bits = (mask >> (d * 13)) & 0x1FFF
if day_bits == 0: continue

# Convert to string binary to find 101 patterns (gap)
bin_str = bin(day_bits)[2:].zfill(13) # LSB is 1st class?
# Actually LSB is node 0 (1st class). `bin` output is MSB left.
# Let's just iterate
has_started = False
gap_count = 0
current_gap = 0
Expand All @@ -96,9 +86,13 @@ def score_schedule(schedule, preferences):
total_gaps += gap_count

if preferences['compactness'] == 'high':
score -= total_gaps * 0.2 # Penalty for gaps
p_val = total_gaps * 0.2
total_penalty += p_val
details['课程紧凑'] = -p_val
else:
score += total_gaps * 0.2 # Bonus for gaps (spread)
b_val = total_gaps * 0.2
total_bonus += b_val
details['课程分散'] = +b_val

# 4. Max Daily Load
limit = preferences.get('max_daily_load')
Expand All @@ -109,40 +103,43 @@ def score_schedule(schedule, preferences):
if mask == 0: continue
for d in range(7):
day_bits = (mask >> (d * 13)) & 0x1FFF
# Count set bits
count = bin(day_bits).count('1')
if count > limit:
overload += (count - limit)
score -= overload * 5.0 # Heavy penalty
p_val = overload * 5.0
total_penalty += p_val
details['每日负载'] = -p_val

# 5. Day Max Limit (Specific Days)
# 5. Day Max Limit
if preferences.get('day_max_limit_enabled'):
limit = preferences.get('day_max_limit_value', 4)
target_days = preferences.get('day_max_limit_days', []) # List of bools, idx 0=Mon

penalty = 0
# Ensure target_days has 7 elements
target_days = preferences.get('day_max_limit_days', [])
if len(target_days) < 7:
target_days = target_days + [False] * (7 - len(target_days))

penalty = 0
for w in range(1, 26):
mask = full_bitmap[w]
if mask == 0: continue

for d in range(7):
# Check if this day is selected for limiting
if not target_days[d]:
continue

if not target_days[d]: continue
day_bits = (mask >> (d * 13)) & 0x1FFF
count = bin(day_bits).count('1')

if count > limit:
# Penalty calculation
# If limit is 0 (day off), any class is bad.
diff = count - limit
penalty += diff * 50.0 # Very heavy penalty
penalty += diff * 50.0

score -= penalty
p_val = penalty
total_penalty += p_val
details['特定日限制'] = -p_val

return score
final_score = base_score + total_bonus - total_penalty
return {
'score': final_score,
'details': details
}

@staticmethod
def score_schedule(schedule, preferences):
result = ScheduleRanker.evaluate_schedule(schedule, preferences)
return result['score']
5 changes: 4 additions & 1 deletion backend/solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ def generate_schedules(groups, max_results=20, preferences=None):
current_bitmap = [0] * 30

counter = itertools.count()
total_found_container = [0]

# Pre-calculate group order?
# Heuristic: Process groups with FEWEST options first (Fail Fast).
Expand All @@ -216,6 +217,8 @@ def generate_schedules(groups, max_results=20, preferences=None):
def backtrack(group_idx, current_schedule_meta):
if group_idx == len(meta_groups):
# Found a valid schedule
total_found_container[0] += 1

# Reconstruct final schedule but include alternatives info
final_schedule = []
for m in current_schedule_meta:
Expand Down Expand Up @@ -281,7 +284,7 @@ def backtrack(group_idx, current_schedule_meta):
backtrack(0, [])

sorted_results = sorted(top_n_heap, key=lambda x: x[0], reverse=True)
return [item[2] for item in sorted_results]
return [item[2] for item in sorted_results], total_found_container[0]

@staticmethod
def is_valid_combination(courses):
Expand Down
13 changes: 13 additions & 0 deletions jwFetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,10 +338,23 @@ def search(self, course_name=None, course_code=None, campus="1", semester="2025-
# 调用正则解析器
bitmap, sessions = ScheduleBitmapper.generate_bitmap(raw_loc)

# Try to extract credit (XF) and hours (XS)
try:
credit = float(row.get("XF", 0))
except:
credit = 0.0

try:
hours = float(row.get("XS", 0))
except:
hours = 0.0

item = {
"name": row.get("KCM"),
"code": row.get("KCH"),
"teacher": teacher,
"credit": credit,
"hours": hours,
"location_text": raw_loc,
"school": row.get("PKDWDM_DISPLAY") or row.get("KKDWDM_DISPLAY"),
# 输出二进制列表 (核心)
Expand Down
118 changes: 112 additions & 6 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,126 @@ def generate_schedules(self, groups, preferences):

# 2. Generate
# Pass preferences to solver for DFS pruning/ordering
raw_schedules = ScheduleSolver.generate_schedules(groups, preferences=preferences)
print(f"[Api] Found {len(raw_schedules)} valid schedules")
raw_schedules, total_count = ScheduleSolver.generate_schedules(groups, preferences=preferences)
print(f"[Api] Found {len(raw_schedules)} top schedules (from {total_count} total explored)")

# 3. Rank
# 3. Rank and Enrich
ranker = ScheduleRanker()
ranked = []
for s in raw_schedules:
score = ranker.score_schedule(s, preferences)
ranked.append({'courses': s, 'score': score})
eval_result = ranker.evaluate_schedule(s, preferences)
score = eval_result['score']
details = eval_result['details']

# Calculate stats
total_credits = 0.0
total_hours = 0

# Find week span
min_week = 999
max_week = -1
has_classes = False

for course in s:
total_credits += course.get('credit', 0)

# Track active weeks for this course
course_weeks = set()

# Try getting from sessions first
sessions = course.get('sessions', [])
if sessions:
for sess in sessions:
if sess['weeks']:
course_weeks.update(sess['weeks'])
else:
# Fallback: scan schedule_bitmaps
# index 1..len
bitmaps = course.get('schedule_bitmaps', [])
for w_idx in range(1, len(bitmaps)):
val = bitmaps[w_idx]
if isinstance(val, str):
try:
val = int(val)
except: val = 0
if val > 0:
course_weeks.add(w_idx)

# Update global stats
if course_weeks:
has_classes = True
min_week = min(min_week, min(course_weeks))
max_week = max(max_week, max(course_weeks))

# Use official hours if available, else calculate
if course.get('hours', 0) > 0:
total_hours += course.get('hours')
else:
# Calculate hours from sessions if available
if sessions:
for sess in sessions:
p_len = sess['end'] - sess['start'] + 1
w_len = len(sess['weeks'])
total_hours += (p_len * w_len)
else:
# Fallback: Count bits in bitmaps for active weeks
bitmaps = course.get('schedule_bitmaps', [])
for w in course_weeks:
if w < len(bitmaps):
val = bitmaps[w]
if isinstance(val, str):
val = int(val)
total_hours += bin(val).count('1')

avg_weekly = 0.0
if has_classes and max_week >= min_week:
span = max_week - min_week + 1
if span > 0:
avg_weekly = total_hours / span

ranked.append({
'courses': s,
'score': score,
'score_details': details,
'stats': {
'total_credits': round(total_credits, 1),
'total_hours': total_hours,
'avg_weekly_hours': round(avg_weekly, 1),
'week_span': f"{min_week}-{max_week}" if has_classes else "N/A"
}
})

# Sort desc
ranked.sort(key=lambda x: x['score'], reverse=True)

return {'schedules': ranked}
return {'schedules': ranked, 'total_found': total_count}

def save_image_dialog(self, base64_data):
import base64
try:
active_window = webview.windows[0]
file_path = active_window.create_file_dialog(
webview.SAVE_DIALOG,
directory='',
save_filename='schedule.png',
file_types=('PNG Image (*.png)', 'All files (*.*)')
)

if file_path:
if isinstance(file_path, (list, tuple)):
file_path = file_path[0]

# Remove header if present
if ',' in base64_data:
base64_data = base64_data.split(',')[1]

with open(file_path, "wb") as f:
f.write(base64.b64decode(base64_data))
return True
return False
except Exception as e:
print(f"[Api] Save Image Error: {e}")
return False

def save_session(self, groups_json, prefs_json):
try:
Expand Down
4 changes: 2 additions & 2 deletions reproduce.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ def test_duplication_repro():
print(f"Loaded {len(groups)} groups.")

# Run solver
schedules = ScheduleSolver.generate_schedules(groups, max_results=10)
print(f"Generated {len(schedules)} schedules.")
schedules, total_count = ScheduleSolver.generate_schedules(groups, max_results=10)
print(f"Generated {len(schedules)} schedules (Total found: {total_count}).")

if not schedules:
print("No schedules found! Checking if candidates are selected...")
Expand Down
24 changes: 24 additions & 0 deletions server.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
127.0.0.1 - - [10/Jan/2026 07:22:19] "GET /static/index.html HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:22:19] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:22:19] "GET /static/app.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:22:19] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:23:49] "GET /static/index.html HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:23:49] "GET /static/app.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:23:49] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:23:49] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:24:21] "GET /static/index.html HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:24:21] "GET /static/app.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:24:21] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:24:21] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:24:50] "GET /static/index.html HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:24:50] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:24:50] "GET /static/app.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:24:50] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:32:44] "GET /static/index.html HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:32:44] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:32:44] "GET /static/app.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:32:44] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:41:07] "GET /static/index.html HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:41:07] "GET /static/app.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:41:07] "GET /static/vue.global.js HTTP/1.1" 200 -
127.0.0.1 - - [10/Jan/2026 07:41:07] "GET /static/html2canvas.min.js HTTP/1.1" 200 -
Loading