packages feed

weekdaze-0.0.0.1: src-lib/WeekDaze/Model/TimetableForDay.hs

{-# LANGUAGE CPP #-}
{-
	Copyright (C) 2013-2016 Dr. Alistair Ward

	This file is part of WeekDaze.

	WeekDaze is free software: you can redistribute it and/or modify
	it under the terms of the GNU General Public License as published by
	the Free Software Foundation, either version 3 of the License, or
	(at your option) any later version.

	WeekDaze is distributed in the hope that it will be useful,
	but WITHOUT ANY WARRANTY; without even the implied warranty of
	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
	GNU General Public License for more details.

	You should have received a copy of the GNU General Public License
	along with WeekDaze.  If not, see <http://www.gnu.org/licenses/>.
-}
{- |
 [@AUTHOR@]	Dr. Alistair Ward

 [@DESCRIPTION@]

	* Define the data-structure, required to hold a single day's bookings, for any /observer/-id.

	* It doesn't reference the parameters of the specific problem, or the means by which a solution is obtained.
-}

module WeekDaze.Model.TimetableForDay(
-- * Types
-- ** Type-synonyms
	TimetableForDay,
	Association,
	Booking,
	GeneralisedLessonRunlength,
	GeneralisedLessonRunlengthByTimeslotId,
--	GeneralisedLessonRunlengths,
	LessonRunlengths,
	RunlengthsByTimeslotIdByLesson,
-- * Constants
--	tag,
--	bookingTag,
-- * Functions
	measureRunlengthAt,
	boundRunlengthAt,
	countRunlengthAt,
	countUnallocatedTimeslots,
	bisectAt,
	extractAdjacentBookings,
	locateUnallocatedTimeslots,
	findGeneralisedLessonRunlengths,
	findGeneralisedLessonRunlengthsByTimeslotId,
--	findSeparatedEqualLessonsBy,
	findSeparatedEqualLessons,
	findSeparatedEqualSubjectLessons,
	findSeparatedEqualLessonRunlengthsByStartingTimeslotIdByLesson,
	measureFreePeriodCompliance,
	spanRunlengthAt,
-- ** Accessors
	getTimeslotId,
	getMaybeLesson,
--	getBookedTimeslotId,
	getBookedLesson,
-- ** Constructor
	mkFreeTimetableForDay,
-- ** Mutators
	defineTimeslot,
-- ** Predicates
	isSubjectBooked,
	isDefined,
-- ** Translation
	xpickle
) where

import			Control.Arrow((&&&), (***))
import			Data.Array.IArray((//), (!))
import qualified	Control.Arrow
import qualified	Data.Array.IArray
import qualified	Data.Foldable
import qualified	Data.List
import qualified	Data.List.Extra
import qualified	Data.Maybe
import qualified	Data.Set
import qualified	Factory.Data.Interval
import qualified	Text.XML.HXT.Arrow.Pickle		as HXT
import qualified	ToolShed.Data.List
import qualified	ToolShed.Data.List.Runlength
import qualified	ToolShed.Data.Pair
import qualified	WeekDaze.Data.Subject			as Data.Subject
import qualified	WeekDaze.Model.Lesson			as Model.Lesson
import qualified	WeekDaze.Size				as Size
import qualified	WeekDaze.Temporal.FreePeriodPreference	as Temporal.FreePeriodPreference
import qualified	WeekDaze.Temporal.Time			as Temporal.Time

-- | Used to qualify XML.
tag :: String
tag		= "timetableForDay"

-- | Used to qualify XML.
bookingTag :: String
bookingTag	= "booking"

{- |
	* The ordered sequence of time-slots within a single unspecified day.

	* The sequence of time-slots are contiguous;
		/double/ /lesson/s are representated by consecutive duplicate /lesson/-definitions,
		free-/period/s are represented by 'Nothing'.

	* Each time-slot has a specific unspecified start-time & probably a constant duration; but that depends on the school, so no such assumption is made here.
-}
type TimetableForDay timeslotId resourceIds level	= Data.Array.IArray.Array timeslotId (Model.Lesson.GeneralisedLesson resourceIds level)

-- | The association on which 'TimetableForDay' is based.
type Association timeslotId resourceIds level	= (timeslotId, Model.Lesson.GeneralisedLesson resourceIds level)

-- | Accessor.
getTimeslotId :: Association timeslotId resourceIds level -> timeslotId
getTimeslotId	= fst

-- | Accessor.
getMaybeLesson :: Association timeslotId resourceIds level -> Model.Lesson.GeneralisedLesson resourceIds level
getMaybeLesson	= snd

-- | True if the /time-slot/ has been booked.
isDefined :: Association timeslotId resourceIds level -> Bool
isDefined	= Data.Maybe.isJust . getMaybeLesson

-- | Defines a pickler to convert the specified /timetable/ to, or from, XML.
xpickle :: (
	Data.Array.IArray.Ix	timeslotId,
	HXT.XmlPickler		level,
	HXT.XmlPickler		resourceIds,
	HXT.XmlPickler		timeslotId,
	Show			level
 ) => HXT.PU (TimetableForDay timeslotId resourceIds level)
xpickle	= HXT.xpElem tag . HXT.xpWrap (
	uncurry Data.Array.IArray.array . (
		(minimum &&& maximum) . map fst &&& id
	),				-- Construct from an association-list; since all time-slots are recorded in the XML, even when no lesson is booked, the timeslotId-bounds can be derived.
	Data.Array.IArray.assocs	-- Deconstruct to an association-list.
 ) . HXT.xpList1 {-can't be null-} $ HXT.xpElem bookingTag HXT.xpickle {-Pair-}

-- | A /lesson/ qualified by the /time-slot/ at which it is booked.
type Booking timeslotId resourceIds level	= (timeslotId, Model.Lesson.Lesson resourceIds level)

-- | Accessor.
getBookedTimeslotId :: Booking timeslotId resourceIds level -> timeslotId
getBookedTimeslotId	= fst

-- | Accessor.
getBookedLesson :: Booking timeslotId resourceIds level -> Model.Lesson.Lesson resourceIds level
getBookedLesson	= snd

-- | Constructor. Create an unallocated /timetable/, for any single day.
mkFreeTimetableForDay :: (Data.Array.IArray.Ix timeslotId, Enum timeslotId) => Factory.Data.Interval.Interval timeslotId -> TimetableForDay timeslotId resourceIds level
mkFreeTimetableForDay timeslotIdBounds	= Data.Array.IArray.array timeslotIdBounds . zip (Factory.Data.Interval.toList timeslotIdBounds) $ repeat Nothing

{- |
	* Bisect the association-list extracted from the 'TimetableForDay', at the specified /timeslotId/, to form two lists, those before & those after the specified /time-slot/.

	* The list of earlier /time-slot/s is reversed, as though looking backwards from the specified /time-slot/.

	* CAVEAT: the association at the specified /timeslotId/ isn't returned in either half.
-}
bisectAt
	:: Data.Array.IArray.Ix timeslotId
	=> timeslotId
	-> TimetableForDay timeslotId resourceIds level
	-> ([Association timeslotId resourceIds level], [Association timeslotId resourceIds level])
bisectAt timeslotId	= (
	dropWhile (
		(>= timeslotId) . getTimeslotId	-- Extract earlier timeslots.
	) . reverse &&& dropWhile (
		(<= timeslotId) . getTimeslotId	-- Extract later timeslots.
	)
 ) . Data.Array.IArray.assocs

{- |
	* Get any /lesson/s immediately adjacent to the specified /time-slot/.

	* The relevance is that adjacent /lesson/s can typically be replicated, to form a longer duration /lesson/,
	but duplication of a non-adjacent one, would leave an awkward gap.
-}
extractAdjacentBookings
	:: Data.Array.IArray.Ix timeslotId
	=> timeslotId
	-> TimetableForDay timeslotId resourceIds level
	-> [Booking timeslotId resourceIds level]
extractAdjacentBookings timeslotId	= uncurry (++) . ToolShed.Data.Pair.mirror (
	take 1 {-either a null or a singleton list; cf. 'head'-} . map (Control.Arrow.second Data.Maybe.fromJust) . takeWhile isDefined
 ) . bisectAt timeslotId

{- |
	* Defines the (lower & upper) bounds, & measures the span-length, of adjacent /lesson/s, equal to that referenced.

	* CAVEAT: if an undefined /time-slot/ is referenced, then the run-length of undefined /time-slot/s will be measured.
-}
measureRunlengthAt :: (
	Data.Array.IArray.Ix	timeslotId,
	Enum			timeslotId,
	Eq			resourceIds,
	Eq			level
 )
	=> timeslotId	-- ^ The identifier of the /time-slot/ at which to measure the run-length of equal /lesson/s.
	-> TimetableForDay timeslotId resourceIds level
	-> (Factory.Data.Interval.Interval timeslotId, Size.NTimeslots)
measureRunlengthAt timeslotId timetableForDay	= (
	(
		toEnum . (i -) *** toEnum . (i +)	-- Identify the minimum & maximum bounds of the runlength.
	) &&& succ {-include the specified timeslotId-} . uncurry (+)
 ) . ToolShed.Data.Pair.mirror (
	length . takeWhile ((== timetableForDay ! timeslotId) . getMaybeLesson)
 ) $ bisectAt timeslotId timetableForDay where
	i	= fromEnum timeslotId

-- | Defines the /timeslotId/-interval, delimiting the span of adjacent /lesson/s, equal to that referenced.
boundRunlengthAt :: (
	Data.Array.IArray.Ix	timeslotId,
	Enum			timeslotId,
	Eq			resourceIds,
	Eq			level
 )
	=> timeslotId	-- ^ The identifier of the /time-slot/ at which to measure the run-length of equal /lesson/s.
	-> TimetableForDay timeslotId resourceIds level
	-> Factory.Data.Interval.Interval timeslotId
boundRunlengthAt timeslotId	= fst . measureRunlengthAt timeslotId

-- | Returns the list of /timeslotId/s where /lesson/s equal to that referenced, are booked.
spanRunlengthAt :: (
	Data.Array.IArray.Ix	timeslotId,
	Enum			timeslotId,
	Eq			resourceIds,
	Eq			level
 )
	=> timeslotId	-- ^ The identifier of the /time-slot/ at which to measure the run-length of equal /lesson/s.
	-> TimetableForDay timeslotId resourceIds level
	-> [timeslotId]
spanRunlengthAt timeslotId	= uncurry enumFromTo . boundRunlengthAt timeslotId

-- | Measures the length of the span of adjacent /lesson/s, equal to that referenced.
countRunlengthAt :: (
	Data.Array.IArray.Ix	timeslotId,
	Enum			timeslotId,
	Eq			resourceIds,
	Eq			level
 )
	=> timeslotId	-- ^ The identifier of the /time-slot/ at which to measure the run-length of equal /lesson/s.
	-> TimetableForDay timeslotId resourceIds level
	-> Size.NTimeslots
countRunlengthAt timeslotId	= snd . measureRunlengthAt timeslotId

-- | True if the specified /subject/ has already been booked at any /time-slot/ in today's /timetable/.
isSubjectBooked :: (
#if !MIN_VERSION_array(0,5,2)
	Data.Array.IArray.Ix	timeslotId,
#endif /* CAVEAT: constraint unnecessary from "Data.Array.IArray-0.5.1.1" */
	Eq			level
 ) => Data.Subject.Subject level -> TimetableForDay timeslotId resourceIds level -> Bool
isSubjectBooked subject	= Data.Foldable.any . Data.Maybe.maybe False {-no lesson booked-} $ (== subject) . Model.Lesson.getSubject

{- |
	* Identifies unallocated /time-slot/s.

	* CAVEAT: doesn't account for the /availability/ of the /resource/, or whether any /time-slot/s has been reserved for /meeting/s.
-}
locateUnallocatedTimeslots :: Data.Array.IArray.Ix timeslotId => TimetableForDay timeslotId resourceIds level -> [timeslotId]
locateUnallocatedTimeslots timetableForDay	= [
	unallocatedTimeslotId |
		(unallocatedTimeslotId, Nothing {-i.e. unallocated-})	<- Data.Array.IArray.assocs timetableForDay
 ] -- List-comprehension.

{- |
	* Counts the /time-slot/s which haven't been yet booked.

	* CAVEAT: doesn't account for the /availability/ of the /resource/, or whether any /time-slot/s has been reserved for /meeting/s.
-}
countUnallocatedTimeslots :: Data.Array.IArray.Ix timeslotId => TimetableForDay timeslotId resourceIds level -> Size.NTimeslots
countUnallocatedTimeslots	= length . locateUnallocatedTimeslots

-- | Define a single /lesson/ in the /timetable/.
defineTimeslot
	:: Data.Array.IArray.Ix timeslotId
	=> Association timeslotId resourceIds level
	-> TimetableForDay timeslotId resourceIds level
	-> TimetableForDay timeslotId resourceIds level
defineTimeslot booking	= (// [booking])

-- | A runlength-encoded list of generalised (i.e. potentially undefined) /lesson/.
type GeneralisedLessonRunlength resourceIds level	= ToolShed.Data.List.Runlength.Code (Model.Lesson.GeneralisedLesson resourceIds level)

-- | A runlength-encoded list of /time-slot/s.
type GeneralisedLessonRunlengths resourceIds level	= [GeneralisedLessonRunlength resourceIds level]

{- |
	* Measures run-lengths of consecutive equal generalised (i.e. potentially undefined) /lesson/s.

	* Includes consecutive unallocated /time-slot/s & trivial sequences of length one.

	* CAVEAT: the /coordinates/ of each runlength aren't explicit; see 'findGeneralisedLessonRunlengthsByTimeslotId'.
-}
findGeneralisedLessonRunlengths :: (
	Data.Array.IArray.Ix	timeslotId,
	Eq			resourceIds,
	Eq			level
 ) => TimetableForDay timeslotId resourceIds level -> GeneralisedLessonRunlengths resourceIds level
findGeneralisedLessonRunlengths	= ToolShed.Data.List.Runlength.encode . Data.Array.IArray.elems

-- | An association-list keyed by the starting /timeslotId/, of runlength-encoded generalised (i.e. potentially undefined) /lesson/s.
type GeneralisedLessonRunlengthByTimeslotId timeslotId resourceIds level	= [(timeslotId, GeneralisedLessonRunlength resourceIds level)]

-- | Calls 'findGeneralisedLessonRunlengths' & derives the /coordinates/ of the start of each runlength.
findGeneralisedLessonRunlengthsByTimeslotId :: (
	Data.Array.IArray.Ix	timeslotId,
	Enum			timeslotId,
	Eq			resourceIds,
	Eq			level
 ) => TimetableForDay timeslotId resourceIds level -> GeneralisedLessonRunlengthByTimeslotId timeslotId resourceIds level
findGeneralisedLessonRunlengthsByTimeslotId timetableForDay	= init {-drop the initial value-} . scanr (
	\runlengthCode (timeslotId, _)	-> (`Temporal.Time.shift` timeslotId) . negate . ToolShed.Data.List.Runlength.getLength	{-derive the starting timeslotId-} &&& id $ runlengthCode
 ) (
	succ . snd {-max-} $ Data.Array.IArray.bounds timetableForDay,	-- The non-existent timeslotId immediately after the last for the day.
	undefined							-- The non-existent generalised lesson at the above time-slot.
 ) $ findGeneralisedLessonRunlengths timetableForDay

-- | A runlength-encoded list of /lesson/s.
type LessonRunlengths resourceIds level	= [ToolShed.Data.List.Runlength.Code (Model.Lesson.Lesson resourceIds level)]

{- |
	* Finds separated equal (according to the attribute returned by the specified accessor) /lesson/s; separated unallocated /time-slot/s don't qualify.

	* CAVEAT: discards the /coordinates/ at which each /lesson/ was booked.
-}
findSeparatedEqualLessonsBy :: (
	Data.Array.IArray.Ix	timeslotId,
	Eq			resourceIds,
	Eq			level,
	Ord			attribute
 )
	=> (Model.Lesson.Lesson resourceIds level -> attribute)	-- ^ Accessor.
	-> TimetableForDay timeslotId resourceIds level
	-> LessonRunlengths resourceIds level
findSeparatedEqualLessonsBy accessor	= filter (
	(/= 1) . ToolShed.Data.List.Runlength.getLength	-- Select non-unique sessions.
 ) . map (
	length &&& head	-- Construct a 'ToolShed.Data.List.Runlength.Code'.
 ) . Data.List.Extra.groupSortOn accessor {-separated sessions-} . Data.Maybe.mapMaybe head {-remove consecutive equal lessons, discounting undefined time-slots-} . Data.List.group {-consecutive equal generalised lessons-} . Data.Array.IArray.elems

{- |
	* Finds separated equal /lesson/s; separated unallocated /time-slot/s don't qualify.

	* CAVEAT: discards the /coordinates/ at which each /lesson/ was booked.
-}
findSeparatedEqualLessons :: (
	Data.Array.IArray.Ix	timeslotId,
	Ord			resourceIds,
	Ord			level
 ) => TimetableForDay timeslotId resourceIds level -> LessonRunlengths resourceIds level
findSeparatedEqualLessons	= findSeparatedEqualLessonsBy id

{- |
	* Finds separated /lesson/s of equal /subject/.

	* CAVEAT: discards the /coordinates/ at which each /lesson/ was booked.
-}
findSeparatedEqualSubjectLessons :: (
	Data.Array.IArray.Ix	timeslotId,
	Eq			resourceIds,
	Ord			level
 ) => TimetableForDay timeslotId resourceIds level -> LessonRunlengths resourceIds level
findSeparatedEqualSubjectLessons	= findSeparatedEqualLessonsBy Model.Lesson.getSubject

-- | A list associated by /lesson/, of lists associated by /timeslotId/.
type RunlengthsByTimeslotIdByLesson resourceIds level timeslotId	= [(Model.Lesson.Lesson resourceIds level, [(timeslotId {-start-}, Size.NTimeslots {-runlength-})])]

{- |
	* Finds runlengths of separated equal /lesson/s; separated unallocated /time-slot/s don't qualify.

	* Returns a list associated by the common /lesson/, of lists associated by starting /timeslotId/, of runlengths.
-}
findSeparatedEqualLessonRunlengthsByStartingTimeslotIdByLesson :: (
	Data.Array.IArray.Ix	timeslotId,
	Ord			resourceIds,
	Ord			level
 ) => TimetableForDay timeslotId resourceIds level -> RunlengthsByTimeslotIdByLesson resourceIds level timeslotId
findSeparatedEqualLessonRunlengthsByStartingTimeslotIdByLesson timetableForDay	= map (
	\l@(
		(
			_,	-- startingTimeslotId.
			(
				_,	-- Run-length.
				lesson
			) -- Pair.
		) : _
	) -> (
		lesson,
		map (
			Control.Arrow.second fst {-runlength-}	-- Drop the common lesson, which has been factored-out.
		) l
	) -- Pair.
 ) . filter (
	(/= 1) . length	-- Select non-unique sessions.
 ) $ Data.List.Extra.groupSortOn (
	snd {-lesson-} . snd {-runlength-code-}	-- Separated equals lessons.
 ) [
	(startingTimeslotId, (length l, lesson)) |
		l@((startingTimeslotId, Just lesson) : _)	<- Data.List.groupBy (
			ToolShed.Data.List.equalityBy snd {-generalised lesson-}	-- Consecutive equal lessons.
		) $ Data.Array.IArray.assocs timetableForDay
 ] -- List-comprehension.

{- |
	Measure the ratio of those free (neither reserved for a /meeting/ nor booked with a /lesson/) /time-slot/s
	which comply with the specified preference,
	to the total number of free /time-slot/s.
-}
measureFreePeriodCompliance
	:: (Data.Array.IArray.Ix timeslotId, Fractional ratio)
	=> Temporal.FreePeriodPreference.FreePeriodPreference
	-> Data.Set.Set timeslotId	-- ^ The /timeslotId/s of meetings, for the groups of which this observer is a member.
	-> TimetableForDay timeslotId resourceIds level
	-> ratio
measureFreePeriodCompliance freePeriodPreference meetingTimeslotIds timetableForDay
	| nFreePeriods `elem` [
		0,
		Data.Array.IArray.rangeSize $ Data.Array.IArray.bounds timetableForDay
	]		= 1	-- CAVEAT: debatable.
	| otherwise	= fromIntegral nCompliantFreePeriods / fromIntegral nFreePeriods
	where
		nCompliantFreePeriods, nFreePeriods :: Size.NTimeslots
		(nCompliantFreePeriods, nFreePeriods)	= (
			countConformantFreePeriods freePeriodPreference &&& length . filter id
		 ) . map (
			uncurry (&&) . ((`Data.Set.notMember` meetingTimeslotIds) *** Data.Maybe.isNothing)	-- Check it's neither reserved for a meeting nor booked with a lesson.
		 ) $ Data.Array.IArray.assocs timetableForDay

		countConformantFreePeriods :: Temporal.FreePeriodPreference.FreePeriodPreference -> [Bool] -> Size.NTimeslots
		countConformantFreePeriods freePeriodPreference'	= case freePeriodPreference' of
			Temporal.FreePeriodPreference.Pre	-> length . takeWhile id
			Temporal.FreePeriodPreference.Post	-> countConformantFreePeriods Temporal.FreePeriodPreference.Pre . reverse
			Temporal.FreePeriodPreference.Terminal	-> uncurry (+) . (countConformantFreePeriods Temporal.FreePeriodPreference.Pre &&& countConformantFreePeriods Temporal.FreePeriodPreference.Post)