Skip to content
Merged
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
15 changes: 13 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,20 @@ jobs:
grep -q "Constraint scores" out/ci-demo/report.html
grep -q "Weekend balance" out/ci-demo/report.html

- name: Upload the demo report
- name: Solve the ward example
run: sbt -batch -no-colors "run data/ward out/ci-ward"

- name: Verify the ward outputs
run: |
test -s out/ci-ward/roster.csv
test -s out/ci-ward/report.html
grep -q "Skill match" out/ci-ward/report.html
grep -q "Tom Walker" out/ci-ward/report.html
grep -q "Ana Reyes" out/ci-ward/report.html

- name: Upload the demo reports
uses: actions/upload-artifact@v4
with:
name: clinic-report
name: reports
path: out/ci-demo
if-no-files-found: error
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
ThisBuild / organization := "io.rotaforge"
ThisBuild / version := "0.1.0"
ThisBuild / version := "0.2.0"
ThisBuild / scalaVersion := "3.3.8"

lazy val root = (project in file("."))
Expand Down
8 changes: 8 additions & 0 deletions data/ward/days.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
date
2026-06-01
2026-06-02
2026-06-03
2026-06-04
2026-06-05
2026-06-06
2026-06-07
14 changes: 14 additions & 0 deletions data/ward/preferences.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
staff,date,shift,weight
W1,2026-06-01,D,5
W1,2026-06-02,N,3
W2,2026-06-03,E,4
W3,2026-06-04,D,5
W5,2026-06-02,E,3
W5,2026-06-05,D,2
W6,2026-06-07,D,4
W4,2026-06-03,D,0
W4,2026-06-03,E,0
W4,2026-06-03,N,0
W2,2026-06-06,D,0
W2,2026-06-06,E,0
W2,2026-06-06,N,0
5 changes: 5 additions & 0 deletions data/ward/rules.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
key,value
maxShiftsPerPerson,5
maxConsecutiveDays,4
minRestHours,12
hardPenalty,1000000
4 changes: 4 additions & 0 deletions data/ward/shifts.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
id,name,start,end,skill,count
D,Day Care,07:00,19:00,C.A.,2
E,Clinical Shift,15:00,03:00,R.N.,1
N,Night Duty,21:00,09:00,*,1
7 changes: 7 additions & 0 deletions data/ward/staff.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
id,name,skill
W1,Maria Costa,R.N.
W2,Yuki Tanaka,R.N.
W3,Omar Diallo,C.A.
W4,Priya Nair,C.A.
W5,Tom Walker,R.N.;C.A.
W6,Ana Reyes,R.N.;C.A.
2 changes: 1 addition & 1 deletion src/main/scala/rotaforge/Main.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import rotaforge.score.ConstraintKind
/** Command line entry point for Rota Forge. */
object Main {

private val version = "0.1.0"
private val version = "0.2.0"

private val usage: String =
s"""Rota Forge $version - staff rostering solver
Expand Down
5 changes: 3 additions & 2 deletions src/main/scala/rotaforge/core/Annealer.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package rotaforge.core

import rotaforge.model.Preference
import rotaforge.model.Skills
import rotaforge.score.Scorer
import scala.util.Random

Expand Down Expand Up @@ -174,8 +175,8 @@ final class Annealer(index: RosterIndex, scorer: Scorer) {
st += 1
}
if (a == -1 || b == -1) return null
val aSkilled = index.shiftSkill(s2) == "*" || index.staffSkill(a) == index.shiftSkill(s2)
val bSkilled = index.shiftSkill(s1) == "*" || index.staffSkill(b) == index.shiftSkill(s1)
val aSkilled = Skills.matches(index.shiftSkills(s2), index.staffSkills(a))
val bSkilled = Skills.matches(index.shiftSkills(s1), index.staffSkills(b))
if (!aSkilled || !bSkilled) return null
if (!canWork(a, day, s2) || !canWork(b, day, s1)) return null
ShiftSwap(day, a, b, s1, s2)
Expand Down
3 changes: 2 additions & 1 deletion src/main/scala/rotaforge/core/InitialRoster.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package rotaforge.core

import rotaforge.model.Preference
import rotaforge.model.Rules
import rotaforge.model.Skills

/**
* Builds a first feasible schedule with a greedy assignment pass.
Expand Down Expand Up @@ -45,7 +46,7 @@ object InitialRoster {
val eligible =
duty(st)(day) == -1 &&
workload(st) < rules.maxShiftsPerPerson &&
(index.shiftSkill(s) == "*" || index.staffSkill(st) == index.shiftSkill(s)) &&
Skills.matches(index.shiftSkills(s), index.staffSkills(st)) &&
!blocked.contains((st, day, s))
if (eligible) {
val key = workload(st)
Expand Down
5 changes: 3 additions & 2 deletions src/main/scala/rotaforge/core/RosterIndex.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package rotaforge.core
import java.time.LocalDate
import rotaforge.model.Roster
import rotaforge.model.ShiftDef
import rotaforge.model.Skills

/**
* Compact integer indexes over a roster.
Expand All @@ -22,7 +23,7 @@ final class RosterIndex(val roster: Roster) {

val staffByIndex: Array[String] = roster.staff.map(_.id).toArray
val staffName: Array[String] = roster.staff.map(_.name).toArray
val staffSkill: Array[String] = roster.staff.map(_.skill).toArray
val staffSkills: Array[Set[String]] = roster.staff.map(s => Skills.parse(s.skill)).toArray

val daysByIndex: Array[LocalDate] = roster.days.toArray
val isWeekend: Array[Boolean] =
Expand All @@ -32,7 +33,7 @@ final class RosterIndex(val roster: Roster) {
val shiftName: Array[String] = roster.shifts.map(_.name).toArray
val startMinute: Array[Int] = roster.shifts.map(_.startMinute).toArray
val endMinute: Array[Int] = roster.shifts.map(_.endMinute).toArray
val shiftSkill: Array[String] = roster.shifts.map(_.skill).toArray
val shiftSkills: Array[Set[String]] = roster.shifts.map(s => Skills.parse(s.skill)).toArray
val headcount: Array[Int] = roster.shifts.map(_.headcount).toArray

def requireValid(): Unit = {
Expand Down
25 changes: 25 additions & 0 deletions src/main/scala/rotaforge/model/Skills.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package rotaforge.model

/**
* Parses and compares skill lists.
*
* A skill column can hold one skill or several. A semicolon separates
* the skills. For example, `R.N.;C.A.` means the staff member can fill
* either a nurse role or a care assistant role. A shift with the
* wildcard `*` accepts any staff member.
*/
object Skills {

/** Split a raw skill column into a set of skills. */
def parse(raw: String): Set[String] =
raw.split(";").iterator.map(_.trim).filter(_.nonEmpty).toSet

/**
* True when the staff member can fill the shift.
*
* The shift is fillable when it takes anyone (the `*` wildcard) or
* when the staff member holds at least one of its required skills.
*/
def matches(shiftSkills: Set[String], staffSkills: Set[String]): Boolean =
shiftSkills.contains("*") || shiftSkills.exists(staffSkills.contains)
}
2 changes: 1 addition & 1 deletion src/main/scala/rotaforge/report/HtmlReport.scala
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ $shiftLegend
</section>
</main>
<footer>
Generated by Rota Forge 0.1.0 from ${escape(meta.inputDir)}.
Generated by Rota Forge 0.2.0 from ${escape(meta.inputDir)}.
Hard rules always hold. A lower score is better.
</footer>
</body>
Expand Down
4 changes: 2 additions & 2 deletions src/main/scala/rotaforge/score/Scorer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import rotaforge.core.Grid
import rotaforge.core.RosterIndex
import rotaforge.model.Preference
import rotaforge.model.Rules
import rotaforge.model.Skills

/**
* Scores a schedule against hard and soft constraints.
Expand Down Expand Up @@ -71,8 +72,7 @@ final class Scorer(index: RosterIndex) {
while (st < nStaff) {
val s = duty(st)(day)
if (s != -1) {
val required = index.shiftSkill(s)
if (required != "*" && required != index.staffSkill(st))
if (!Skills.matches(index.shiftSkills(s), index.staffSkills(st)))
violations += 1
}
st += 1
Expand Down
8 changes: 8 additions & 0 deletions src/test/resources/ward/days.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
date
2026-06-01
2026-06-02
2026-06-03
2026-06-04
2026-06-05
2026-06-06
2026-06-07
14 changes: 14 additions & 0 deletions src/test/resources/ward/preferences.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
staff,date,shift,weight
W1,2026-06-01,D,5
W1,2026-06-02,N,3
W2,2026-06-03,E,4
W3,2026-06-04,D,5
W5,2026-06-02,E,3
W5,2026-06-05,D,2
W6,2026-06-07,D,4
W4,2026-06-03,D,0
W4,2026-06-03,E,0
W4,2026-06-03,N,0
W2,2026-06-06,D,0
W2,2026-06-06,E,0
W2,2026-06-06,N,0
5 changes: 5 additions & 0 deletions src/test/resources/ward/rules.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
key,value
maxShiftsPerPerson,5
maxConsecutiveDays,4
minRestHours,12
hardPenalty,1000000
4 changes: 4 additions & 0 deletions src/test/resources/ward/shifts.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
id,name,start,end,skill,count
D,Day Care,07:00,19:00,C.A.,2
E,Clinical Shift,15:00,03:00,R.N.,1
N,Night Duty,21:00,09:00,*,1
7 changes: 7 additions & 0 deletions src/test/resources/ward/staff.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
id,name,skill
W1,Maria Costa,R.N.
W2,Yuki Tanaka,R.N.
W3,Omar Diallo,C.A.
W4,Priya Nair,C.A.
W5,Tom Walker,R.N.;C.A.
W6,Ana Reyes,R.N.;C.A.
12 changes: 11 additions & 1 deletion src/test/scala/rotaforge/InputLoaderSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ class InputLoaderSuite extends munit.FunSuite {

private val staffCsv =
"id,name,skill\nN1,Ana Silva,R.N.\nN2,Boris Chen,R.N.\n"

private val daysCsv =
"date\n2026-05-04\n2026-05-05\n2026-05-06\n"

Expand Down Expand Up @@ -88,4 +87,15 @@ class InputLoaderSuite extends munit.FunSuite {
}
assert(error.getMessage.contains("staff.csv"))
}

test("a semicolon skill list loads as several skills") {
val dir = Files.createTempDirectory("rotaforge-multiskill")
write(dir, "staff.csv", "id,name,skill\nN1,Ana Silva,R.N.\nN2,Bo Lin,R.N.;L.P.N.\n")
write(dir, "days.csv", daysCsv)
write(dir, "shifts.csv", shiftsCsv)

val index = new rotaforge.core.RosterIndex(InputLoader.load(dir))
assertEquals(index.staffSkills(index.staffIdx("N2")), Set("R.N.", "L.P.N."))
assertEquals(index.staffSkills(index.staffIdx("N1")), Set("R.N."))
}
}
31 changes: 31 additions & 0 deletions src/test/scala/rotaforge/ScorerSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,37 @@ class ScorerSuite extends munit.FunSuite {
assert(result.hardViolations > 0)
}

private def dualRoster: rotaforge.model.Roster =
TestRosters.roster(
extraStaff = Vector(
rotaforge.model.Staff("E", "Elena", "R.N.;L.P.N."),
rotaforge.model.Staff("F", "Feng", "L.P.N.")
),
extraShifts = Vector(
rotaforge.model.ShiftDef("A", "Care Assist", 8 * 60, 20 * 60, "L.P.N.", 1)
)
)

test("a multi-skilled staff member fills shifts that need either skill") {
val roster = dualRoster
val grid = TestRosters.grid(
roster,
Map(
"A" -> Map(d0 -> "D"),
"E" -> Map(d1 -> "D", d2 -> "A")
)
)
val result = score(grid, roster)
assertEquals(result.byKey("skill-match").get.violations, 0)
}

test("a staff member without the skill still violates on a multi-skill shift") {
val roster = dualRoster
val grid = TestRosters.grid(roster, Map("F" -> Map(d0 -> "D")))
val result = score(grid, roster)
assertEquals(result.byKey("skill-match").get.violations, 1)
}

test("a zero-weight preference blocks an assignment") {
val roster = TestRosters.roster(Vector(Preference("A", d0, "D", 0)))
val grid = TestRosters.grid(roster, Map("A" -> Map(d0 -> "D")))
Expand Down
43 changes: 43 additions & 0 deletions src/test/scala/rotaforge/SkillsSuite.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package rotaforge

import rotaforge.model.Skills

class SkillsSuite extends munit.FunSuite {

test("parse keeps a single skill") {
assertEquals(Skills.parse("R.N."), Set("R.N."))
}

test("parse splits a semicolon list") {
assertEquals(Skills.parse("R.N.;C.A."), Set("R.N.", "C.A."))
}

test("parse trims surrounding spaces") {
assertEquals(Skills.parse(" R.N. ; C.A. "), Set("R.N.", "C.A."))
}

test("parse ignores empty entries") {
assertEquals(Skills.parse("R.N.;;C.A."), Set("R.N.", "C.A."))
}

test("matches accepts the exact skill") {
assert(Skills.matches(Set("R.N."), Set("R.N.")))
}

test("matches accepts when the staff hold one of several required skills") {
assert(Skills.matches(Set("R.N.", "C.A."), Set("C.A.")))
}

test("matches rejects a staff member without the skill") {
assert(!Skills.matches(Set("R.N."), Set("C.A.")))
}

test("matches rejects a staff member with no matching skill") {
assert(!Skills.matches(Set("R.N.", "C.A."), Set("P.T.")))
}

test("the wildcard shift accepts any staff member") {
assert(Skills.matches(Set("*"), Set("R.N.")))
assert(Skills.matches(Set("*"), Set("C.A.")))
}
}
6 changes: 4 additions & 2 deletions src/test/scala/rotaforge/TestRosters.scala
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ object TestRosters {
/** Build a roster from the shared fixture, adding preferences. */
def roster(
preferences: Vector[rotaforge.model.Preference] = Vector.empty,
rulesOverride: Rules = rules
rulesOverride: Rules = rules,
extraStaff: Vector[Staff] = Vector.empty,
extraShifts: Vector[ShiftDef] = Vector.empty
): Roster =
Roster(days, staff, shifts, preferences, rulesOverride)
Roster(days, staff ++ extraStaff, shifts ++ extraShifts, preferences, rulesOverride)

def index(roster: Roster): RosterIndex = new RosterIndex(roster)

Expand Down
Loading
Loading