diff --git a/Project.toml b/Project.toml index c4a53a3..6ff81d5 100644 --- a/Project.toml +++ b/Project.toml @@ -1,10 +1,10 @@ -name = "NurseScheduling" +name = "NurseSchedulingSolver" uuid = "92cd2c7d-a66b-49fa-9b79-6db41388eebc" +authors = ["Paweł Renc and contributors"] +version = "0.1.0" [deps] DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" -Genie = "c43c736e-a2d1-11e8-161f-af95117fbd1e" -HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" LoggingExtras = "e6f89c97-d47a-5376-807f-9c37f3926c36" @@ -14,3 +14,9 @@ Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" StatsBase = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" SuperEnum = "5c958174-e7d9-5990-9618-4567de4ba542" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[extras] +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[targets] +test = ["Test"] diff --git a/config/default/shifts.json b/config/default/shifts.json deleted file mode 100644 index b2748e4..0000000 --- a/config/default/shifts.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "R" : { - "from" : 7, - "to" : 15, - "is_working_shift" : true - }, - "P" : { - "from" : 15, - "to" : 19, - "is_working_shift" : true - }, - "D" : { - "from" : 7, - "to" : 19, - "is_working_shift" : true - }, - "N" : { - "from" : 19, - "to" : 7, - "is_working_shift" : true - }, - "DN" : { - "from" : 7, - "to" : 7, - "is_working_shift" : true - }, - "PN" : { - "from" : 15, - "to" : 7, - "is_working_shift" : true - }, - "W" : { - "from" : 7, - "to" : 15, - "is_working_shift" : false - }, - "U" : { - "from" : 7, - "to" : 15, - "is_working_shift" : false - }, - "L4" : { - "from" : 7, - "to" : 15, - "is_working_shift" : false - } -} \ No newline at end of file diff --git a/src/server.jl b/server/server.jl similarity index 100% rename from src/server.jl rename to server/server.jl diff --git a/src/NurseScheduling.jl b/src/NurseScheduling.jl deleted file mode 100644 index e59716a..0000000 --- a/src/NurseScheduling.jl +++ /dev/null @@ -1,35 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at https://mozilla.org/MPL/2.0/. -module NurseSchedules - -export Schedule, - Neighborhood, - score, - get_shift_options, - get_penalties, - get_shifts, - get_max_nbhd_size, - get_month_info, - get_workers_info, - update_shifts!, - n_split_nbhd, - perform_random_jumps!, - get_shifts_distance, - Shifts - -using JSON -using SuperEnum - -include("constants.jl") -include("shifts.jl") -include("schedule.jl") -include("validation.jl") -include("scoring.jl") -include("neighborhood.jl") - -using .ScheduleValidation -using .ScheduleScoring -using .NeighborhoodGen - -end # NurseSchedules diff --git a/src/NurseSchedulingSolver.jl b/src/NurseSchedulingSolver.jl new file mode 100644 index 0000000..44a9d27 --- /dev/null +++ b/src/NurseSchedulingSolver.jl @@ -0,0 +1,8 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +module NurseSchedulingSolver + +include("model/Model.jl") + +end diff --git a/config/default/priorities.json b/src/defaults/priorities.json similarity index 100% rename from config/default/priorities.json rename to src/defaults/priorities.json diff --git a/src/defaults/shifts.json b/src/defaults/shifts.json new file mode 100644 index 0000000..cc03a2d --- /dev/null +++ b/src/defaults/shifts.json @@ -0,0 +1,38 @@ +[ + { + "code": "R", + "from": 7, + "to": 15, + "type": "WORKING" + }, + { + "code": "P", + "from": 15, + "to": 19, + "type": "WORKING" + }, + { + "code": "D", + "from": 7, + "to": 19, + "type": "WORKING" + }, + { + "code": "N", + "from": 19, + "to": 7, + "type": "WORKING" + }, + { + "code": "DN", + "from": 7, + "to": 7, + "type": "WORKING" + }, + { + "code": "PN", + "from": 15, + "to": 7, + "type": "WORKING" + } +] diff --git a/src/logger.jl b/src/logging/logger.jl similarity index 100% rename from src/logger.jl rename to src/logging/logger.jl diff --git a/src/logConstants.jl b/src/logging/parameters.jl similarity index 100% rename from src/logConstants.jl rename to src/logging/parameters.jl diff --git a/src/model/Model.jl b/src/model/Model.jl new file mode 100644 index 0000000..8f2e1f9 --- /dev/null +++ b/src/model/Model.jl @@ -0,0 +1,33 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +module Model + +export Schedule + # Neighborhood, + # get_shift_options, + # get_penalties, + # get_shifts, + # get_max_nbhd_size, + # get_month_info, + # get_workers_info, + # update_shifts!, + # n_split_nbhd, + # perform_random_jumps!, + # get_shifts_distance, + # Shifts + +include("schedule.jl") +# include("scoring.jl") +# include("neighborhood.jl") + +using .schedule + +# using .scoring +# export score + +# using .neighborhood + +# using .schedulevalidation + +end diff --git a/src/model/constants.jl b/src/model/constants.jl new file mode 100644 index 0000000..bf29b70 --- /dev/null +++ b/src/model/constants.jl @@ -0,0 +1,37 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +module constants + +using JSON + +const PROJECT_SRC = dirname(@__FILE__) |> dirname + +const DEFAULT_SHIFTS = JSON.parsefile(joinpath(PROJECT_SRC, "defaults/shifts.json")) +const DEFAULT_CONFIG = JSON.parsefile(joinpath(PROJECT_SRC, "defaults/priorities.json")) + +const REQ_CHLDN_PER_NRS_DAY = 3 +const REQ_CHLDN_PER_NRS_NIGHT = 5 + +const LONG_BREAK_HOURS = 35 + +# under and overtime pen is equal to hours from <0, MAX_OVERTIME> +const MAX_OVERTIME = 10 # scaled by the number of weeks +const MAX_UNDERTIME = 0 # scaled by the number of weeks + +const DAY_BEGIN = 6 +const NIGHT_BEGIN = 22 + +const PERIOD_BEGIN = 7 + +# weekly worktime +const WORKTIME_BASE = 40 + +const DAY_HOURS_NO = 24 +const WEEK_DAYS_NO = 7 +const NUM_WORKING_DAYS = 5 +const SUNDAY_NO = 0 + +const WORKTIME_DAILY = WORKTIME_BASE / NUM_WORKING_DAYS + +end # constants diff --git a/src/neighborhood.jl b/src/model/neighborhood.jl similarity index 100% rename from src/neighborhood.jl rename to src/model/neighborhood.jl diff --git a/src/model/schedule.jl b/src/model/schedule.jl new file mode 100644 index 0000000..48145c1 --- /dev/null +++ b/src/model/schedule.jl @@ -0,0 +1,166 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +module schedule + +export Schedule, + Shifts, + shifts, + base_shifts, + actual_shifts, + decode_shifts, + employee_uuid, + employee_shifts, + employee_base_shifts, + employee_actual_shifts + +using JSON + +include("constants.jl") +include("types.jl") + +using .constants: DEFAULT_SHIFTS +using .types: BasicShift + +Shifts = Matrix{UInt8} + +struct Schedule + meta::Dict{String,Any} + shift_coding::Dict{String,UInt8} + + function Schedule(schedule_json::Dict) + available_shifts = get(schedule_json, "available_shifts", DEFAULT_SHIFTS) + shift_coding = _make_shift_coding(available_shifts) + + new(schedule_json, shift_coding) + end + + Schedule(filepath::String) = Schedule(JSON.parsefile(filepath)) +end + +function _make_shift_coding(available_shifts::Vector)::Dict{String,UInt8} + shift_coding = Dict( + string(instance) => UInt8(instance) for + instance in instances(BasicShift.BasicShiftEnum) + ) + + for shift_info in available_shifts + if !haskey(shift_coding, shift_info["code"]) + shift_coding[shift_info["code"]] = length(shift_coding) + end + end + return shift_coding +end + +# schedule getters +function shifts(schedule::Schedule, shifts_key::String)::Shifts + shifts = [ + map(code -> schedule.shift_coding[code], e[shifts_key]) for + e in schedule.meta["employees"] + ] + shifts = transpose(hcat(shifts...)) + return shifts +end + +base_shifts(schedule::Schedule)::Shifts = shifts(schedule, "base_shifts") +actual_shifts(schedule::Schedule)::Shifts = shifts(schedule, "actual_shifts") + +function decode_shifts(schedule::Schedule, shifts::Shifts)::Matrix{String} + decoding = Dict(v => k for (k, v) in pairs(schedule.shift_coding)) + return map(coded_shift -> decoding[coded_shift], shifts) +end + +employee_uuid(schedule::Schedule, idx::Int)::String = + schedule.meta["employees"][idx]["uuid"] + +employee_shifts(schedule::Schedule, idx::Int, shifts_key::String)::Vector{String} = + schedule.meta["employees"][idx][shifts_key] + +employee_actual_shifts(schedule::Schedule, idx::Int)::Vector{String} = + employee_shifts(schedule, idx, "actual_shifts") + +employee_base_shifts(schedule::Schedule, idx::Int)::Vector{String} = + employee_shifts(schedule, idx, "base_shifts") + +########################################### +# ☠☠☠ WATCH OUT! ☠☠☠ # +# everything below is possibly deprecated # +########################################### + +function raw_options(schedule::Schedule) + return "shift_types" in keys(schedule.data) ? schedule.data["shift_types"] : SHIFTS +end + +function shift_options(schedule::Schedule) + base = raw_options(schedule) + return Dict(schedule.shift_map[k] => v for (k, v) in base) +end + +function penalties(schedule::Schedule)::Dict{String,Any} + weights = CONFIG["weight_map"] + default_priority = CONFIG["penalties"] + custom_priority = get(schedule.data, "penalty_priorities", nothing) + penalties = Dict{String,Any}() + + if isnothing(custom_priority) + return Dict(key => pen for (key, pen) in zip(default_priority, weights)) + end + + for key in default_priority + if key in custom_priority + penalties[key] = weights[findall(x -> x == key, custom_priority)[1]] + else + penalties[key] = 0 + end + end + + return penalties +end + +function daytime_range(schedule::Schedule) + daytime_begin = get(schedule.meta["settings"], "daytime_begin", DAY_BEGIN) + daytime_end = get(schedule.meta["settings"], "night_begin", NIGHT_BEGIN) + return (daytime_begin, daytime_end) +end + +function changeable_shifts(schedule::Schedule) + filter(kv -> kv.second["is_working_shift"], shift_options(schedule)) +end + +function exempted_shifts(schedule::Schedule) + filter( + kv -> !kv.second["is_working_shift"] && kv.first != W_ID, + get_shift_options(schedule), + ) +end + +function disallowed_sequences(schedule::Schedule) + Dict( + first_shift_key => [ + second_shift_key for + (second_shift_key, second_shift_val) in get_changeable_shifts(schedule) if + get_next_day_distance(first_shift_val, second_shift_val) <= + get_rest_length(first_shift_val) + ] for (first_shift_key, first_shift_val) in changeable_shifts(schedule) + ) +end + +function earliest_shift_begin(schedule::Schedule) + changeable_shifts = collect(values(changeable_shifts(schedule))) + minimum(x -> x["from"], changeable_shifts) +end + +function latest_shift_end(schedule::Schedule) + changeable_shifts = collect(values(changeable_shifts(schedule))) + maximum(x -> x["to"] > x["from"] ? x["to"] : 24 + x["to"], changeable_shifts) +end + +function get_period_range()::Vector{Int} + vcat(collect(PERIOD_BEGIN:24), collect(1:(PERIOD_BEGIN - 1))) +end + +function get_shifts_distance(shifts_1::Shifts, shifts_2::Shifts)::Int + return count(s -> s[1] != s[2], zip(shifts_1, shifts_2)) +end + +end # schedule diff --git a/src/scoring.jl b/src/model/scoring.jl similarity index 87% rename from src/scoring.jl rename to src/model/scoring.jl index 389deb4..5de33af 100644 --- a/src/scoring.jl +++ b/src/model/scoring.jl @@ -1,67 +1,40 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. -module ScheduleScoring +module scoring export score -import Base.+ - -using ..NurseSchedules: - Schedule, - get_penalties, - get_workers_info, - get_month_info, - get_shift_options, - get_disallowed_sequences, - get_changeable_shifts, - get_exempted_shifts, - get_earliest_shift_begin, - get_latest_shift_end, - get_day, - get_period_range, - get_interval_length, - sum_segments, - ScoringResult, - ScoringResultOrPenalty, - ScheduleShifts, - Shifts, - DayShifts, - Workers, - REQ_CHLDN_PER_NRS_DAY, - PERIOD_BEGIN, - REQ_CHLDN_PER_NRS_NIGHT, - LONG_BREAK_HOURS, - MAX_OVERTIME, - MAX_UNDERTIME, - WORKTIME_BASE, - WEEK_DAYS_NO, - NUM_WORKING_DAYS, - DAY_HOURS_NO, - SUNDAY_NO, - WORKTIME_DAILY, - PERIOD_BEGIN, - Constraints, - WorkerType, - ErrorCode, - within, - get_shift_length - -(+)(l::ScoringResult, r::ScoringResult) = - ScoringResult((l.penalty + r.penalty, vcat(l.errors, r.errors))) +include("constants.jl") +include("types.jl") +include("schedule.jl") + +using .schedule: + ScheduleMeta, + penalties, + workers_info, + month_info, + shift_options, + disallowed_sequences, + changeable_shifts, + exempted_shifts, + earliest_shift_begin, + latest_shift_end, + day + function score( schedule_shifts::ScheduleShifts, - schedule::Schedule; + schedule_meta::ScheduleMeta; return_errors::Bool = false, )::ScoringResultOrPenalty score_res = ScoringResult((0, [])) - score_res += ck_workers_presence(schedule_shifts, schedule) + score_res += _eval_workers_presence(schedule_shifts, schedule_meta) - score_res += ck_workers_rights(schedule_shifts, schedule) + score_res += _eval_workers_rights(schedule_shifts, schedule_meta) - score_res += ck_workers_worktime(schedule_shifts, schedule) + score_res += _eval_workers_worktime(schedule_shifts, schedule_meta) if return_errors score_res @@ -70,17 +43,18 @@ function score( end end -function ck_workers_presence( +function _eval_workers_presence( schedule_shifts::ScheduleShifts, - schedule::Schedule, + schedule_meta::ScheduleMeta, )::ScoringResult workers, shifts = schedule_shifts score_res = ScoringResult((0, [])) + for day_no in axes(shifts, 2) day_shifts = shifts[:, day_no] - score_res += ck_workers_to_children(day_no, day_shifts, schedule) - score_res += ck_nurse_presence(day_no, workers, day_shifts, schedule) - score_res += ck_daily_workers_teams(day_shifts, day_no, workers, schedule) + score_res += _eval_workers_to_children(day_no, day_shifts, schedule_meta) + score_res += _eval_nurse_presence(day_no, workers, day_shifts, schedule_meta) + score_res += _eval_daily_workers_teams(day_shifts, day_no, workers, schedule_meta) end if score_res.penalty > 0 @debug "Lacking workers total penalty: $(score_res.penalty)" @@ -89,10 +63,10 @@ function ck_workers_presence( end # WNN/WND -function ck_workers_to_children( +function _eval_workers_to_children( day::Int, day_shifts::DayShifts, - schedule::Schedule, + schedule::ScheduleMeta, )::ScoringResult shift_info = get_shift_options(schedule) month_info = get_month_info(schedule) @@ -114,14 +88,14 @@ function ck_workers_to_children( day_segments_begin = nothing night_segments = [] night_segments_begin = nothing - + act_wrk_day = req_wrk_day act_wrk_night = req_wrk_night for hour in get_period_range() current_workers = count([ within(hour, shift_info[shift]) - for shift in day_shifts + for shift in day_shifts ]) if hour >= day_begin && hour < day_end # day @@ -130,7 +104,7 @@ function ck_workers_to_children( push!(night_segments, (night_segments_begin, hour)) night_segments_begin = nothing end - if current_workers < req_wrk_day + if current_workers < req_wrk_day if isnothing(day_segments_begin) day_segments_begin = hour end @@ -213,7 +187,7 @@ function ck_workers_to_children( end # AON -function ck_nurse_presence(day::Int, wrks, day_shifts, schedule::Schedule)::ScoringResult +function _eval_nurse_presence(day::Int, wrks, day_shifts, schedule::ScheduleMeta)::ScoringResult shift_info = get_shift_options(schedule) workers_info = get_workers_info(schedule) penalties = get_penalties(schedule) @@ -271,11 +245,11 @@ function ck_nurse_presence(day::Int, wrks, day_shifts, schedule::Schedule)::Scor end #WMT -function ck_daily_workers_teams( +function _eval_daily_workers_teams( day_shifts::DayShifts, day::Int, workers::Workers, - schedule::Schedule + schedule::ScheduleMeta )::ScoringResult penalty = 0 errors = [] @@ -300,9 +274,9 @@ function ck_daily_workers_teams( ] workers_hourly = [ [ - worker + worker for (num, worker) in enumerate(workers) - if within(hour, shifts[day_shifts[num]]) + if within(hour, shifts[day_shifts[num]]) ] for hour = 1:24 ] @@ -324,15 +298,15 @@ function ck_daily_workers_teams( end ### LLB + DSS -function ck_workers_rights( +function _eval_workers_rights( schedule_shitfs::ScheduleShifts, - schedule::Schedule, + schedule::ScheduleMeta, )::ScoringResult workers, shifts = schedule_shitfs penalties = get_penalties(schedule) if penalties[string(Constraints.PEN_DISALLOWED_SHIFT_SEQ)] == 0 - return ck_workers_long_breaks(schedule_shitfs, schedule) + return _eval_workers_long_breaks(schedule_shitfs, schedule) end disallowed_shift_seq = get_disallowed_sequences(schedule) @@ -367,19 +341,19 @@ function ck_workers_rights( end end end - return ScoringResult((penalty, errors)) + ck_workers_long_breaks(schedule_shitfs, schedule) + return ScoringResult((penalty, errors)) + _eval_workers_long_breaks(schedule_shitfs, schedule) end # LLB -function ck_workers_long_breaks( +function _eval_workers_long_breaks( schedule_shitfs::ScheduleShifts, - schedule::Schedule + schedule::ScheduleMeta )::ScoringResult penalty = 0 errors = Vector{Dict{String,Any}}() workers, shifts = schedule_shitfs penalties = get_penalties(schedule) - + if penalties[string(Constraints.PEN_NO_LONG_BREAK)] == 0 return ScoringResult((0, [])) end @@ -435,9 +409,9 @@ function ck_workers_long_breaks( return ScoringResult((penalty, errors)) end -function ck_workers_worktime( +function _eval_workers_worktime( schedule_shifts::ScheduleShifts, - schedule::Schedule, + schedule::ScheduleMeta, )::ScoringResult shift_info = get_shift_options(schedule) month_info = get_month_info(schedule) @@ -518,4 +492,5 @@ function ck_workers_worktime( end return ScoringResult((penalty, errors)) end -end # ScheduleScoring + +end # scoring diff --git a/src/shifts.jl b/src/model/shifts.jl similarity index 99% rename from src/shifts.jl rename to src/model/shifts.jl index d20207f..d3ea42e 100644 --- a/src/shifts.jl +++ b/src/model/shifts.jl @@ -1,6 +1,7 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. + # Shift hours are half open interval # [from, to) @@ -57,7 +58,7 @@ function get_rest_length(shift::ShiftType)::Int end end -# Assumption +# ASSUMPTION # Always: # DAY_BEGIN < DAY_END # Thus night periods always crosses midnight, and day shift never @@ -107,4 +108,4 @@ function sum_segments(segments)::Int hours += get_interval_length(start, stop) end return hours -end \ No newline at end of file +end diff --git a/src/constants.jl b/src/model/types.jl similarity index 54% rename from src/constants.jl rename to src/model/types.jl index 5955e2a..3e0389f 100644 --- a/src/constants.jl +++ b/src/model/types.jl @@ -1,64 +1,22 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. -# CUSTOM TYPES -# -# Scoring -ScoringResult = @NamedTuple{penalty::Int, errors::Vector{Dict{String,Any}}} -ScoringResultOrPenalty = Union{ScoringResult,Int} -# Schedule related -Workers = Vector{String} -Shifts = Matrix{UInt8} -DayShifts = Vector{UInt8} -ScheduleShifts = Tuple{Workers,Shifts} + +# Custom types used across the project +module types + +using SuperEnum + +import Base.+ + +# Super enums + # Neighborhood @se Mutation begin ADD => "ADDITION" DEL => "DELETION" SWP => "SWAP" end -IntOrTuple = Union{Int,Tuple{Int,Int}} -IntOrNothing = Union{UInt8,Nothing} -MutationRecipe = @NamedTuple{ - type::Mutation.MutationEnum, - day::Int, - wrk_no::IntOrTuple, - optional_info::IntOrNothing, -} - -# day free dict -const W = "W" -const W_ID = 0 -const W_DICT = Dict("from" => 7, - "to" => 15, - "is_working_shift" => false) - -const REQ_CHLDN_PER_NRS_DAY = 3 -const REQ_CHLDN_PER_NRS_NIGHT = 5 - -# there has to be such a seq each week -const LONG_BREAK_HOURS = 35 - -# under and overtime pen is equal to hours from <0, MAX_OVERTIME> -const MAX_OVERTIME = 10 # scaled by the number of weeks -const MAX_UNDERTIME = 0 # scaled by the number of weeks - -const CONFIG = JSON.parsefile("config/default/priorities.json") -const SHIFTS = JSON.parsefile("config/default/shifts.json") -const DAY_BEGIN = 6 -const NIGHT_BEGIN = 22 - -const PERIOD_BEGIN = 7 - -# weekly worktime -const WORKTIME_BASE = 40 - -const DAY_HOURS_NO = 24 -const WEEK_DAYS_NO = 7 -const NUM_WORKING_DAYS = 5 -const SUNDAY_NO = 0 - -const WORKTIME_DAILY = WORKTIME_BASE / NUM_WORKING_DAYS @se Constraints begin PEN_LACKING_NURSE => "AON" @@ -74,6 +32,24 @@ end OTHER => "OTHER" end +@se ContractType begin + EMPLOYMENT => "EMPLOYMENT" + CIVIL => "CIVIL" +end + +@se ShiftType begin + WORKING => "WORKING" + NONWORKING => "NONWORKING" + UTIL => "UTIL" + NONWORKING_DIFF => "NONWORKING_DIFF" +end + +@se BasicShift begin + NONWORKING => "W" + SICK_LEAVE => "L4" + VACATION => "U" +end + @se ErrorCode begin ALWAYS_AT_LEAST_ONE_NURSE => "AON" WORKERS_NO_DURING_DAY => "WND" @@ -84,3 +60,21 @@ end WORKER_UNDERTIME_HOURS => "WUH" WORKER_OVERTIME_HOURS => "WOH" end + +# Scoring +ScoringResult = @NamedTuple{penalty::Int, errors::Vector{Dict{String,Any}}} +ScoringResultOrPenalty = Union{ScoringResult,Int} + +(+)(l::ScoringResult, r::ScoringResult) = + ScoringResult((l.penalty + r.penalty, vcat(l.errors, r.errors))) + +IntOrTuple = Union{Int,Tuple{Int,Int}} +IntOrNothing = Union{UInt8,Nothing} +MutationRecipe = @NamedTuple{ + type::Mutation.MutationEnum, + day::Int, + wrk_no::IntOrTuple, + optional_info::IntOrNothing, +} + +end diff --git a/src/validation.jl b/src/model/validation.jl similarity index 99% rename from src/validation.jl rename to src/model/validation.jl index 991621d..bd59a46 100644 --- a/src/validation.jl +++ b/src/model/validation.jl @@ -1,7 +1,7 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. -module ScheduleValidation +module schedulevalidation using JSON diff --git a/src/schedule.jl b/src/schedule.jl deleted file mode 100644 index 3e0cb83..0000000 --- a/src/schedule.jl +++ /dev/null @@ -1,169 +0,0 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this -# file, You can obtain one at https://mozilla.org/MPL/2.0/. -using ..NurseSchedules: - CONFIG, - SHIFTS, - W, - W_ID, - W_DICT, - PERIOD_BEGIN, - get_next_day_distance, - get_rest_length - -mutable struct Schedule - data::Dict - shift_map::Dict{String, UInt8} - reverse_map::Dict{UInt8, String} - - function Schedule(filename::AbstractString) - data = JSON.parsefile(filename) - Schedule(data) - end - - function Schedule(data::Dict{String,Any}) - validate(data) - @debug "Schedule loaded correctly." - (shift_map, reverse_map) = if "shift_types" in keys(data) - construct_maps(keys(data["shift_types"])) - else - construct_maps(keys(SHIFTS)) - end - new(data, shift_map, reverse_map) - end -end - -function construct_maps(keys) - shift_map = Dict{String, UInt8}() - reverse_map = Dict{UInt8, String}() - reverse_map[W_ID] = W - shift_map[W] = W_ID - next_val = W_ID + 1 - for key in keys - if key != W - shift_map[key] = next_val - reverse_map[next_val] = key - next_val += 1 - end - end - shift_map, reverse_map -end - -function get_raw_options(schedule::Schedule) - if !("shift_types" in keys(schedule.data)) - SHIFTS - else - schedule.data["shift_types"] - end -end - -function get_penalties(schedule)::Dict{String,Any} - weights = CONFIG["weight_map"] - default_priority = CONFIG["penalties"] - custom_priority = get(schedule.data, "penalty_priorities", nothing) - penalties = Dict{String,Any}() - - if isnothing(custom_priority) - return Dict( - key => pen - for (key, pen) in zip(default_priority, weights) - ) - end - - for key in default_priority - if key in custom_priority - penalties[key] = weights[findall(x -> x == key, custom_priority)[1]] - else - penalties[key] = 0 - end - end - - return penalties -end - -function get_shift_options(schedule::Schedule) - base = get_raw_options(schedule) - return Dict(schedule.shift_map[k] => v for (k,v) in base) -end - -function get_day(schedule::Schedule) - day_begin = get(schedule.data["month_info"], "day_begin", DAY_BEGIN) - day_end = get(schedule.data["month_info"], "night_begin", NIGHT_BEGIN) - return (day_begin, day_end) -end - -function get_changeable_shifts(schedule::Schedule) - filter( - kv -> kv.second["is_working_shift"], - get_shift_options(schedule) - ) -end - -function get_exempted_shifts(schedule::Schedule) - filter( - kv -> !kv.second["is_working_shift"] && kv.first != W_ID, - get_shift_options(schedule) - ) -end - -function get_disallowed_sequences(schedule::Schedule) - Dict( - first_shift_key => [ - second_shift_key - for (second_shift_key, second_shift_val) in get_changeable_shifts(schedule) - if get_next_day_distance(first_shift_val, second_shift_val) <= get_rest_length(first_shift_val) - ] for (first_shift_key, first_shift_val) in get_changeable_shifts(schedule) - ) -end - - -function get_earliest_shift_begin(schedule::Schedule) - changeable_shifts = collect(values(get_changeable_shifts(schedule))) - minimum(x -> x["from"], - changeable_shifts - ) -end - -function get_latest_shift_end(schedule::Schedule) - changeable_shifts = collect(values(get_changeable_shifts(schedule))) - maximum(x -> x["to"] > x["from"] ? x["to"] : 24 + x["to"], - changeable_shifts - ) -end - -function get_shifts(schedule::Schedule)::ScheduleShifts - workers = collect(keys(schedule.data["shifts"])) - shifts = collect(map( - x -> map(y -> schedule.shift_map[y], x), - values(schedule.data["shifts"]) - )) - - return workers, - [shifts[person][shift] for person = 1:length(shifts), shift = 1:length(shifts[1])] -end - -function get_month_info(schedule::Schedule)::Dict{String,Any} - return schedule.data["month_info"] -end - -function get_workers_info(schedule::Schedule)::Dict{String,Any} - return schedule.data["employee_info"] -end - -function update_shifts!(schedule::Schedule, shifts) - workers, _ = get_shifts(schedule) - for worker_no in axes(shifts, 1) - schedule.data["shifts"][workers[worker_no]] = map(x -> schedule.reverse_map[x], shifts[worker_no, :]) - end -end - -function get_period_range()::Vector{Int} - vcat( - collect(PERIOD_BEGIN:24), - collect(1:(PERIOD_BEGIN - 1)) - ) -end - -get_shifts_distance(shifts_1::Shifts, shifts_2::Shifts)::Int = - count(s -> s[1] != s[2], zip(shifts_1, shifts_2)) - diff --git a/src/parameters.jl b/src/solvers/parameters.jl similarity index 88% rename from src/parameters.jl rename to src/solvers/parameters.jl index 56b03fa..8c2ca14 100644 --- a/src/parameters.jl +++ b/src/solvers/parameters.jl @@ -1,6 +1,8 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. + +# Reactive Tabu Search parameters const ITERATION_NUMBER = 3000 const INITIAL_MAX_TABU_SIZE = 150 const INC_TABU_SIZE_ITER = 0 @@ -9,7 +11,6 @@ const SCHEDULE_PATH = "schedules/schedule_2016_august_frontend.json" const NO_IMPROVE_QUIT_ITERS = 100 const IMPROVE_DELTA = 0.01 -# reactive tabu search const FULL_NBHD_ITERS = 30 const EXTENDED_NBHD_ITERS = 4 @@ -24,6 +25,6 @@ const NBHD_OPT_PEN = 200 # Percentage of nhbd checked while penalty is higher than the threshold const NBHD_OPT_SAMPLE_SIZE = 0.2 -# Shuffle schedule if stuck in local optima +# Shuffle schedule if stuck in local optima (perturbations) const MAX_NO_IMPROVS = 10 const NO_RANDOM_CHANGES = 250 diff --git a/src/repair_schedule.jl b/src/solvers/tabusearch.jl similarity index 100% rename from src/repair_schedule.jl rename to src/solvers/tabusearch.jl diff --git a/test/runtests.jl b/test/runtests.jl index 050836b..68924b3 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,25 +1,6 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. -cd("../") - -include("old_engine/NurseScheduling.jl") -include("../src/repair_schedule.jl") - using Test -using .NurseSchedules: - Schedule, - get_shifts, - get_disallowed_sequences, - update_shifts!, - get_next_day_distance, - get_earliest_shift_begin, - get_latest_shift_end, - sum_segments, - within, - W_ID, - W -include("engine_tests.jl") -include("shifts.jl") include("schedule.jl") diff --git a/test/schedule.jl b/test/schedule.jl index 2302983..011b273 100644 --- a/test/schedule.jl +++ b/test/schedule.jl @@ -1,13 +1,72 @@ -# This Source Code Form is subject to the terms of the Mozilla Public -# License, v. 2.0. If a copy of the MPL was not distributed with this +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +using NurseSchedulingSolver.Model: + Schedule, + base_shifts, + actual_shifts, + decode_shifts, + employee_uuid, + employee_shifts, + employee_base_shifts, + employee_actual_shifts -@testset "update_shifts!" begin - schedule1 = Schedule("schedules/schedule_2016_august_medium.json") - (workers, shifts) = get_shifts(schedule1) - @test schedule1.data["shifts"][workers[1]][1] != W - shifts[1, 1] = W_ID - update_shifts!(schedule1, shifts) - @test schedule1.data != Schedule("schedules/schedule_2016_august_medium.json") - @test schedule1.data["shifts"][workers[1]][1] == W -end \ No newline at end of file +using NurseSchedulingSolver.Model.schedule: _make_shift_coding, BasicShift, DEFAULT_SHIFTS + +SCHEDULE_PATH = "schedules/2016_august_medium_new_form.json" + +@testset "Parse schedule from JSON" begin + #TODO make sure that all jsons in the schedule dir are parsable and valid + + schedule1 = Schedule(SCHEDULE_PATH) + + @test true +end + +@testset "Schedule coding" begin + @testset "Create shift coding" begin + shift_coding = _make_shift_coding([]) + basic_shift_count = length(instances(BasicShift.BasicShiftEnum)) + @test length(shift_coding) == basic_shift_count + + custom_shift_count = length(DEFAULT_SHIFTS) + shift_coding = _make_shift_coding(DEFAULT_SHIFTS) + @test length(shift_coding) == basic_shift_count + custom_shift_count + @test values(shift_coding) |> unique |> length == length(shift_coding) + + #TODO test against all our schedules + schedule = Schedule(SCHEDULE_PATH) + shift_coding = _make_shift_coding(schedule.meta["available_shifts"]) + @testset "Schedule: $(basename(SCHEDULE_PATH))" begin + @test values(shift_coding) |> unique |> length == length(shift_coding) + end + end + + @testset "Shifts coding" begin + schedule = Schedule(SCHEDULE_PATH) + + @testset "Convert shifts to the UInt8 representation" begin + num_employees = length(schedule.meta["employees"]) + num_days = length(schedule.meta["employees"][1]["base_shifts"]) + + shifts = base_shifts(schedule) + @test typeof(shifts) == Matrix{UInt8} + @test size(shifts) == (num_employees, num_days) + + shifts = actual_shifts(schedule) + @test typeof(actual_shifts(schedule)) == Matrix{UInt8} + @test size(shifts) == (num_employees, num_days) + end + + @testset "Convert shifts back to the string representation" begin + shifts = base_shifts(schedule) + decoded_shifts = decode_shifts(schedule, shifts) + @test typeof(decoded_shifts) == Matrix{String} + + for e_no in axes(decoded_shifts, 1) + @test decoded_shifts[e_no, :] == employee_base_shifts(schedule, e_no) + end + end + + end +end diff --git a/schedules/schedule_2016_august.json b/test/schedules/2016_august.json similarity index 100% rename from schedules/schedule_2016_august.json rename to test/schedules/2016_august.json diff --git a/schedules/schedule_2016_august_covid.json b/test/schedules/2016_august_covid.json similarity index 100% rename from schedules/schedule_2016_august_covid.json rename to test/schedules/2016_august_covid.json diff --git a/schedules/schedule_2016_august_custom.json b/test/schedules/2016_august_custom.json similarity index 100% rename from schedules/schedule_2016_august_custom.json rename to test/schedules/2016_august_custom.json diff --git a/schedules/schedule_2016_august_extended.json b/test/schedules/2016_august_extended.json similarity index 100% rename from schedules/schedule_2016_august_extended.json rename to test/schedules/2016_august_extended.json diff --git a/schedules/schedule_2016_august_frontend.json b/test/schedules/2016_august_frontend.json similarity index 100% rename from schedules/schedule_2016_august_frontend.json rename to test/schedules/2016_august_frontend.json diff --git a/schedules/schedule_2016_august_medium.json b/test/schedules/2016_august_medium.json similarity index 100% rename from schedules/schedule_2016_august_medium.json rename to test/schedules/2016_august_medium.json diff --git a/test/schedules/2016_august_medium_new_form.json b/test/schedules/2016_august_medium_new_form.json new file mode 100644 index 0000000..ea9239d --- /dev/null +++ b/test/schedules/2016_august_medium_new_form.json @@ -0,0 +1,356 @@ +{ + "employees": [ + { + "uuid": "nurse_1", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "NURSE", + "base_shifts": [ "R", "R", "W", "W", "D", "DN", "W" ], + "actual_shifts": [ "R", "R", "W", "W", "D", "DN", "W"] + }, + { + "uuid": "nurse_2", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "NURSE", + "base_shifts": [ "N", "W", "N", "W", "W", "W", "DN" ], + "actual_shifts": [ "N", "W", "N", "W", "W", "W", "DN" ] + }, + { + "uuid": "nurse_3", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "NURSE", + "base_shifts": [ "DN", "W", "DN", "N", "W", "DN", "N" ], + "actual_shifts": [ "DN", "W", "DN", "N", "W", "DN", "N" ] + }, + { + "uuid": "nurse_4", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "NURSE", + "base_shifts": [ "N", "W", "D", "D", "N", "W", "D" ], + "actual_shifts": [ "N", "W", "D", "D", "N", "W", "D" ] + }, + { + "uuid": "nurse_5", + "working_time": 0.5, + "contract_type": "EMPLOYMENT", + "type": "NURSE", + "base_shifts": [ "W", "W", "W", "W", "W", "W", "W" ], + "actual_shifts": [ "W", "W", "W", "W", "W", "W", "W" ] + }, + { + "uuid": "nurse_6", + "working_time": 0.5, + "contract_type": "EMPLOYMENT", + "type": "NURSE", + "base_shifts": [ "U", "U", "U", "U", "U", "U", "U" ], + "actual_shifts": [ "U", "U", "U", "U", "U", "U", "U" ] + }, + { + "uuid": "nurse_7", + "working_time": 0.5, + "contract_type": "EMPLOYMENT", + "type": "NURSE", + "base_shifts": [ + "U", + "U", + "U", + "U", + "U", + "U", + "U" + ], + "actual_shifts": [ + "U", + "U", + "U", + "U", + "U", + "U", + "U" + ] + }, + { + "uuid": "nurse_8", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "NURSE", + "base_shifts": [ + "D", + "N", + "W", + "D", + "R", + "W", + "D" + ], + "actual_shifts": [ + "D", + "N", + "W", + "D", + "R", + "W", + "D" + ] + }, + { + "uuid": "nurse_9", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "NURSE", + "base_shifts": [ + "W", + "DN", + "W", + "W", + "DN", + "W", + "W" + ], + "actual_shifts": [ + "W", + "DN", + "W", + "W", + "DN", + "W", + "W" + ] + }, + { + "uuid": "babysitter_1", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "OTHER", + "base_shifts": [ + "U", + "U", + "U", + "U", + "U", + "U", + "U" + ], + "actual_shifts": [ + "U", + "U", + "U", + "U", + "U", + "U", + "U" + ] + }, + { + "uuid": "babysitter_2", + "working_time": 0.5, + "contract_type": "EMPLOYMENT", + "type": "OTHER", + "base_shifts": [ + "L4", + "L4", + "L4", + "L4", + "L4", + "L4", + "L4" + ], + "actual_shifts": [ + "L4", + "L4", + "L4", + "L4", + "L4", + "L4", + "L4" + ] + }, + { + "uuid": "babysitter_3", + "working_time": 0.5, + "contract_type": "EMPLOYMENT", + "type": "OTHER", + "base_shifts": [ "U", "U", "U", "U", "U", "U", "U" ], + "actual_shifts": [ "U", "U", "U", "U", "U", "U", "U" ] + }, + { + "uuid": "babysitter_4", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "OTHER", + "base_shifts": [ "D", "N", "W", "D", "N", "W", "DN" ], + "actual_shifts": [ "D", "N", "W", "D", "N", "W", "DN" ] + }, + { + "uuid": "babysitter_5", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "OTHER", + "base_shifts": [ "D", "P", "N", "N", "W", "W", "W" ], + "actual_shifts": [ "D", "P", "N", "N", "W", "W", "W" ] + }, + { + "uuid": "babysitter_6", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "OTHER", + "base_shifts": [ "W", "N", "N", "N", "N", "N", "W" ], + "actual_shifts": [ "W", "N", "N", "N", "N", "N", "W" ] + }, + { + "uuid": "babysitter_7", + "working_time": 0.5, + "contract_type": "EMPLOYMENT", + "type": "OTHER", + "base_shifts": [ "W", "D", "D", "W", "D", "DN", "W" ], + "actual_shifts": [ "W", "D", "D", "W", "D", "DN", "W" ] + }, + { + "uuid": "babysitter_8", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "OTHER", + "base_shifts": [ "L4", "L4", "L4", "L4", "L4", "L4", "L4" ], + "actual_shifts": [ "L4", "L4", "L4", "L4", "L4", "L4", "L4" ] + }, + { + "uuid": "babysitter_9", + "working_time": 1.0, + "contract_type": "EMPLOYMENT", + "type": "OTHER", + "base_shifts": [ "N", "W", "D", "N", "P", "W", "W" ], + "actual_shifts": [ "N", "W", "D", "N", "P", "W", "W" ] + }, + { + "uuid": "babysitter_10", + "working_time": 0.5, + "contract_type": "EMPLOYMENT", + "type": "OTHER", + "base_shifts": [ "W", "D", "W", "D", "W", "D", "W" ], + "actual_shifts": [ + "W", + "D", + "W", + "D", + "W", + "D", + "W" + ] + } + ], + "available_shifts": [ + { + "code": "R", + "type": "WORKING", + "from": 7, + "to": 15 + }, + { + "code": "P", + "type": "WORKING", + "from": 15, + "to": 19 + }, + { + "code": "D", + "type": "WORKING", + "from": 7, + "to": 19 + }, + { + "code": "N", + "type": "WORKING", + "from": 19, + "to": 7 + }, + { + "code": "DN", + "type": "WORKING", + "from": 7, + "to": 7 + }, + { + "code": "PN", + "type": "WORKING", + "from": 15, + "to": 7 + }, + { + "code": "W", + "type": "NON-WORKING" + }, + { + "code": "U", + "type": "NON-WORKING" + }, + { + "code": "L4", + "type": "NON-WORKING" + } + ], + "month_meta": [ + { + "children": 24, + "extra_workers": 4, + "is_frozen": false, + "is_holiday": false, + "day_of_month": 1, + "day_of_week": 1 + }, + { + "children": 21, + "extra_workers": 4, + "is_frozen": false, + "is_holiday": false, + "day_of_month": 2, + "day_of_week": 2 + }, + { + "children": 21, + "extra_workers": 4, + "is_frozen": false, + "is_holiday": false, + "day_of_month": 3, + "day_of_week": 3 + }, + { + "children": 21, + "extra_workers": 4, + "is_frozen": false, + "is_holiday": false, + "day_of_month": 4, + "day_of_week": 4 + }, + { + "children": 21, + "extra_workers": 4, + "is_frozen": false, + "is_holiday": false, + "day_of_month": 5, + "day_of_week": 5 + }, + { + "children": 21, + "extra_workers": 4, + "is_frozen": false, + "is_holiday": false, + "day_of_month": 6, + "day_of_week": 6 + }, + { + "children": 21, + "extra_workers": 4, + "is_frozen": false, + "is_holiday": true, + "day_of_month": 7, + "day_of_week": 7 + } + ], + "settings": { + "daytime_begin": 7, + "night_begin": 19 + } +} diff --git a/schedules/schedule_2016_august_medium_with_priorities.json b/test/schedules/2016_august_medium_with_priorities.json similarity index 100% rename from schedules/schedule_2016_august_medium_with_priorities.json rename to test/schedules/2016_august_medium_with_priorities.json diff --git a/schedules/schedule_2016_august_unsolvable.json b/test/schedules/2016_august_unsolvable.json similarity index 100% rename from schedules/schedule_2016_august_unsolvable.json rename to test/schedules/2016_august_unsolvable.json diff --git a/schedules/schedule_2016_september.json b/test/schedules/2016_september.json similarity index 100% rename from schedules/schedule_2016_september.json rename to test/schedules/2016_september.json diff --git a/test/schedules/convert_schedule.py b/test/schedules/convert_schedule.py new file mode 100755 index 0000000..5cffadf --- /dev/null +++ b/test/schedules/convert_schedule.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +import json +import argparse + +parser = argparse.ArgumentParser( + description="Convert an old schedule file to the new structure." +) +parser.add_argument("filepath", type=str, help="Path to the schedule file.") +parser.add_argument( + "-n", + "--nono", + action="store_true", + help="Print reformated file to stdout instead of overwriting a file", +) +args = parser.parse_args() + +with open(args.filepath) as f: + schedule = json.load(f) + + +new_schedule = dict() +new_schedule["employees"] = list() + +for uuid, time in schedule["employee_info"]["time"].items(): + new_schedule["employees"].append( + {"uuid": uuid, "working_time": time, "contract_type": "EMPLOYMENT"} + ) + +for uuid, employee_type in schedule["employee_info"]["type"].items(): + employees = new_schedule["employees"] + employee_obj = next(filter(lambda x: x["uuid"] == uuid, employees), None) + if employee_obj is not None: + employee_obj["type"] = employee_type + +for uuid, shifts in schedule["shifts"].items(): + employee_obj = next(filter(lambda x: x["uuid"] == uuid, employees), None) + if employee_obj is not None: + employee_obj["base_shifts"] = shifts + employee_obj["actual_shifts"] = shifts + +new_schedule["available_shifts"] = list() +for shift_code, meta in schedule["shift_types"].items(): + new_shift = { + "code": shift_code, + "type": "WORKING" if meta["is_working_shift"] else "NON-WORKING", + } + if new_shift["type"] == "WORKING": + new_shift["from"] = meta["from"] + new_shift["to"] = meta["to"] + + new_schedule["available_shifts"].append(new_shift) + +new_schedule["month_meta"] = list() +for i, children_number in enumerate(schedule["month_info"]["children_number"]): + month_info = schedule["month_info"] + new_schedule["month_meta"].append( + { + "children": children_number, + "extra_workers": month_info["extra_workers"][i], + "is_frozen": i + 1 in month_info["frozen_shifts"], + "is_holiday": i + 1 in month_info["holidays"], + "day_of_month": i - 30 if i > 30 else i + 1, + "day_of_week": i % 7 + 1, + } + ) + +new_schedule["settings"] = { + "daytime_begin": schedule["month_info"]["day_begin"], + "night_begin": schedule["month_info"]["night_begin"], +} + +if args.nono: + print(json.dumps(new_schedule, indent=4)) +else: + with open(args.filepath, "w") as f: + json.dump(new_schedule, f, indent=4)