blackopsrepl commited on
Commit
e510416
·
1 Parent(s): 1da6a88

Add Employee Scheduling Quickstart Demo

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +27 -0
  2. Dockerfile +24 -0
  3. README.md +69 -2
  4. deploy/employee-scheduling/Chart.yaml +6 -0
  5. deploy/employee-scheduling/templates/_helpers.tpl +49 -0
  6. deploy/employee-scheduling/templates/deployment.yaml +70 -0
  7. deploy/employee-scheduling/templates/service.yaml +18 -0
  8. deploy/employee-scheduling/values.yaml +35 -0
  9. logging.conf +30 -0
  10. pyproject.toml +20 -0
  11. src/employee_scheduling/__init__.py +19 -0
  12. src/employee_scheduling/__pycache__/__init__.cpython-310.pyc +0 -0
  13. src/employee_scheduling/__pycache__/__init__.cpython-312.pyc +0 -0
  14. src/employee_scheduling/__pycache__/__init__.cpython-313.pyc +0 -0
  15. src/employee_scheduling/__pycache__/constraints.cpython-310.pyc +0 -0
  16. src/employee_scheduling/__pycache__/constraints.cpython-312.pyc +0 -0
  17. src/employee_scheduling/__pycache__/constraints.cpython-313.pyc +0 -0
  18. src/employee_scheduling/__pycache__/converters.cpython-310.pyc +0 -0
  19. src/employee_scheduling/__pycache__/converters.cpython-312.pyc +0 -0
  20. src/employee_scheduling/__pycache__/demo_data.cpython-310.pyc +0 -0
  21. src/employee_scheduling/__pycache__/demo_data.cpython-312.pyc +0 -0
  22. src/employee_scheduling/__pycache__/domain.cpython-310.pyc +0 -0
  23. src/employee_scheduling/__pycache__/domain.cpython-312.pyc +0 -0
  24. src/employee_scheduling/__pycache__/domain.cpython-313.pyc +0 -0
  25. src/employee_scheduling/__pycache__/json_serialization.cpython-310.pyc +0 -0
  26. src/employee_scheduling/__pycache__/json_serialization.cpython-312.pyc +0 -0
  27. src/employee_scheduling/__pycache__/rest_api.cpython-310.pyc +0 -0
  28. src/employee_scheduling/__pycache__/rest_api.cpython-312.pyc +0 -0
  29. src/employee_scheduling/__pycache__/solver.cpython-310.pyc +0 -0
  30. src/employee_scheduling/__pycache__/solver.cpython-312.pyc +0 -0
  31. src/employee_scheduling/constraints.py +213 -0
  32. src/employee_scheduling/converters.py +95 -0
  33. src/employee_scheduling/demo_data.py +228 -0
  34. src/employee_scheduling/domain.py +95 -0
  35. src/employee_scheduling/json_serialization.py +27 -0
  36. src/employee_scheduling/rest_api.py +56 -0
  37. src/employee_scheduling/solver.py +23 -0
  38. static/app.js +520 -0
  39. static/index.html +133 -0
  40. static/webjars/solverforge/css/solverforge-webui.css +68 -0
  41. static/webjars/solverforge/img/solverforge-favicon.svg +65 -0
  42. static/webjars/solverforge/img/solverforge-horizontal-white.svg +66 -0
  43. static/webjars/solverforge/img/solverforge-horizontal.svg +65 -0
  44. static/webjars/solverforge/img/solverforge-logo-stacked.svg +73 -0
  45. static/webjars/solverforge/js/solverforge-webui.js +142 -0
  46. tests/__pycache__/test_constraints.cpython-310-pytest-8.2.2.pyc +0 -0
  47. tests/__pycache__/test_constraints.cpython-311-pytest-8.2.2.pyc +0 -0
  48. tests/__pycache__/test_constraints.cpython-312-pytest-8.2.2.pyc +0 -0
  49. tests/__pycache__/test_feasible.cpython-310-pytest-8.2.2.pyc +0 -0
  50. tests/__pycache__/test_feasible.cpython-311-pytest-8.2.2.pyc +0 -0
.gitignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ target
2
+ local
3
+ __pycache__
4
+ .pytest_cache
5
+ /*.egg-info
6
+ /**/dist
7
+ /**/*.egg-info
8
+ /**/*-stubs
9
+ .venv
10
+
11
+ # Eclipse, Netbeans and IntelliJ files
12
+ /.*
13
+ !/.github
14
+ !/.ci
15
+ !.gitignore
16
+ !.gitattributes
17
+ !/.mvn
18
+ /nbproject
19
+ *.ipr
20
+ *.iws
21
+ *.iml
22
+
23
+ # Repository wide ignore mac DS_Store files
24
+ .DS_Store
25
+ *.code-workspace
26
+ CLAUDE.md
27
+ DOCUMENTATION_AUDIT.md
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Python 3.12 base image
2
+ FROM python:3.12
3
+
4
+ # Install JDK 21 (required for solverforge-legacy)
5
+ RUN apt-get update && \
6
+ apt-get install -y wget gnupg2 && \
7
+ wget -O- https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor > /usr/share/keyrings/adoptium-archive-keyring.gpg && \
8
+ echo "deb [signed-by=/usr/share/keyrings/adoptium-archive-keyring.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list && \
9
+ apt-get update && \
10
+ apt-get install -y temurin-21-jdk && \
11
+ apt-get clean && \
12
+ rm -rf /var/lib/apt/lists/*
13
+
14
+ # Copy application files
15
+ COPY . .
16
+
17
+ # Install the application
18
+ RUN pip install --no-cache-dir -e .
19
+
20
+ # Expose port 8080
21
+ EXPOSE 8080
22
+
23
+ # Run the application
24
+ CMD ["run-app"]
README.md CHANGED
@@ -1,12 +1,79 @@
1
  ---
2
- title: Employee Scheduling Python
3
  emoji: 👀
4
  colorFrom: gray
5
  colorTo: green
6
  sdk: docker
 
7
  pinned: false
8
  license: apache-2.0
9
  short_description: SolverForge Quickstart for the Employee Scheduling problem
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Employee Scheduling (Python)
3
  emoji: 👀
4
  colorFrom: gray
5
  colorTo: green
6
  sdk: docker
7
+ app_port: 8080
8
  pinned: false
9
  license: apache-2.0
10
  short_description: SolverForge Quickstart for the Employee Scheduling problem
11
  ---
12
 
13
+ # Employee Scheduling (Python)
14
+
15
+ Schedule shifts to employees, accounting for employee availability and shift skill requirements.
16
+
17
+ - [Prerequisites](#prerequisites)
18
+ - [Run the application](#run-the-application)
19
+ - [Test the application](#test-the-application)
20
+
21
+ ## Prerequisites
22
+
23
+ 1. Install [Python 3.11 or 3.12](https://www.python.org/downloads/).
24
+
25
+ 2. Install JDK 17+, for example with [Sdkman](https://sdkman.io):
26
+
27
+ ```sh
28
+ $ sdk install java
29
+ ```
30
+
31
+ ## Run the application
32
+
33
+ 1. Git clone the solverforge-solver-python repo and navigate to this directory:
34
+
35
+ ```sh
36
+ $ git clone https://github.com/SolverForge/solverforge-quickstarts.git
37
+ ...
38
+ $ cd solverforge-quickstarts/employee-scheduling-fast
39
+ ```
40
+
41
+ 2. Create a virtual environment:
42
+
43
+ ```sh
44
+ $ python -m venv .venv
45
+ ```
46
+
47
+ 3. Activate the virtual environment:
48
+
49
+ ```sh
50
+ $ . .venv/bin/activate
51
+ ```
52
+
53
+ 4. Install the application:
54
+
55
+ ```sh
56
+ $ pip install -e .
57
+ ```
58
+
59
+ 5. Run the application:
60
+
61
+ ```sh
62
+ $ run-app
63
+ ```
64
+
65
+ 6. Visit [http://localhost:8080](http://localhost:8080) in your browser.
66
+
67
+ 7. Click on the **Solve** button.
68
+
69
+ ## Test the application
70
+
71
+ 1. Run tests:
72
+
73
+ ```sh
74
+ $ pytest
75
+ ```
76
+
77
+ ## More information
78
+
79
+ Visit [solverforge.org](https://www.solverforge.org).
deploy/employee-scheduling/Chart.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ apiVersion: v2
2
+ name: employee-scheduling
3
+ description: A Helm chart for Employee Scheduling application
4
+ type: application
5
+ version: 1.0.1
6
+ appVersion: "1.0.1"
deploy/employee-scheduling/templates/_helpers.tpl ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {{/*
2
+ Expand the name of the chart.
3
+ */}}
4
+ {{- define "employee-scheduling.name" -}}
5
+ {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
6
+ {{- end }}
7
+
8
+ {{/*
9
+ Create a default fully qualified app name.
10
+ */}}
11
+ {{- define "employee-scheduling.fullname" -}}
12
+ {{- if .Values.fullnameOverride }}
13
+ {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
14
+ {{- else }}
15
+ {{- $name := default .Chart.Name .Values.nameOverride }}
16
+ {{- if contains $name .Release.Name }}
17
+ {{- .Release.Name | trunc 63 | trimSuffix "-" }}
18
+ {{- else }}
19
+ {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
20
+ {{- end }}
21
+ {{- end }}
22
+ {{- end }}
23
+
24
+ {{/*
25
+ Create chart name and version as used by the chart label.
26
+ */}}
27
+ {{- define "employee-scheduling.chart" -}}
28
+ {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
29
+ {{- end }}
30
+
31
+ {{/*
32
+ Common labels
33
+ */}}
34
+ {{- define "employee-scheduling.labels" -}}
35
+ helm.sh/chart: {{ include "employee-scheduling.chart" . }}
36
+ {{ include "employee-scheduling.selectorLabels" . }}
37
+ {{- if .Chart.AppVersion }}
38
+ app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
39
+ {{- end }}
40
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
41
+ {{- end }}
42
+
43
+ {{/*
44
+ Selector labels
45
+ */}}
46
+ {{- define "employee-scheduling.selectorLabels" -}}
47
+ app.kubernetes.io/name: {{ include "employee-scheduling.name" . }}
48
+ app.kubernetes.io/instance: {{ .Release.Name }}
49
+ {{- end }}
deploy/employee-scheduling/templates/deployment.yaml ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: {{ include "employee-scheduling.fullname" . }}
5
+ labels:
6
+ {{- include "employee-scheduling.labels" . | nindent 4 }}
7
+ spec:
8
+ replicas: {{ .Values.replicaCount }}
9
+ selector:
10
+ matchLabels:
11
+ {{- include "employee-scheduling.selectorLabels" . | nindent 6 }}
12
+ template:
13
+ metadata:
14
+ labels:
15
+ {{- include "employee-scheduling.selectorLabels" . | nindent 8 }}
16
+ {{- with .Values.podAnnotations }}
17
+ annotations:
18
+ {{- toYaml . | nindent 8 }}
19
+ {{- end }}
20
+ spec:
21
+ {{- if .Values.imagePullSecrets }}
22
+ imagePullSecrets:
23
+ {{- toYaml .Values.imagePullSecrets | nindent 8 }}
24
+ {{- end }}
25
+ {{- with .Values.podSecurityContext }}
26
+ securityContext:
27
+ {{- toYaml . | nindent 8 }}
28
+ {{- end }}
29
+ containers:
30
+ - name: {{ include "employee-scheduling.name" . }}
31
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
32
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
33
+ ports:
34
+ - name: http
35
+ containerPort: {{ .Values.service.port }}
36
+ protocol: TCP
37
+ {{- with .Values.securityContext }}
38
+ securityContext:
39
+ {{- toYaml . | nindent 12 }}
40
+ {{- end }}
41
+ {{- if .Values.resources }}
42
+ resources:
43
+ {{- toYaml .Values.resources | nindent 12 }}
44
+ {{- end }}
45
+ livenessProbe:
46
+ httpGet:
47
+ path: /
48
+ port: http
49
+ initialDelaySeconds: 10
50
+ periodSeconds: 10
51
+ failureThreshold: 3
52
+ readinessProbe:
53
+ httpGet:
54
+ path: /
55
+ port: http
56
+ initialDelaySeconds: 5
57
+ periodSeconds: 5
58
+ failureThreshold: 3
59
+ {{- if .Values.nodeSelector }}
60
+ nodeSelector:
61
+ {{- toYaml .Values.nodeSelector | nindent 8 }}
62
+ {{- end }}
63
+ {{- if .Values.tolerations }}
64
+ tolerations:
65
+ {{- toYaml .Values.tolerations | nindent 8 }}
66
+ {{- end }}
67
+ {{- if .Values.affinity }}
68
+ affinity:
69
+ {{- toYaml .Values.affinity | nindent 8 }}
70
+ {{- end }}
deploy/employee-scheduling/templates/service.yaml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ apiVersion: v1
2
+ kind: Service
3
+ metadata:
4
+ name: {{ include "employee-scheduling.fullname" . }}
5
+ labels:
6
+ {{- include "employee-scheduling.labels" . | nindent 4 }}
7
+ spec:
8
+ type: {{ .Values.service.type }}
9
+ ports:
10
+ - port: {{ .Values.service.port }}
11
+ targetPort: http
12
+ protocol: TCP
13
+ name: http
14
+ {{- if .Values.service.nodePort }}
15
+ nodePort: {{ .Values.service.nodePort }}
16
+ {{- end }}
17
+ selector:
18
+ {{- include "employee-scheduling.selectorLabels" . | nindent 4 }}
deploy/employee-scheduling/values.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ replicaCount: 1
2
+
3
+ image:
4
+ repository: employee-scheduling
5
+ pullPolicy: IfNotPresent
6
+ tag: "1.0.1"
7
+
8
+ imagePullSecrets: []
9
+ nameOverride: ""
10
+ fullnameOverride: ""
11
+
12
+ podAnnotations: {}
13
+
14
+ podSecurityContext: {}
15
+
16
+ securityContext: {}
17
+
18
+ service:
19
+ type: NodePort
20
+ port: 8080
21
+ nodePort: 30081
22
+
23
+ resources: {}
24
+
25
+ autoscaling:
26
+ enabled: false
27
+ minReplicas: 1
28
+ maxReplicas: 100
29
+ targetCPUUtilizationPercentage: 80
30
+
31
+ nodeSelector: {}
32
+
33
+ tolerations: []
34
+
35
+ affinity: {}
logging.conf ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [loggers]
2
+ keys=root,timefold_solver
3
+
4
+ [handlers]
5
+ keys=consoleHandler
6
+
7
+ [formatters]
8
+ keys=simpleFormatter
9
+
10
+ [logger_root]
11
+ level=INFO
12
+ handlers=consoleHandler
13
+
14
+ [logger_timefold_solver]
15
+ level=INFO
16
+ qualname=timefold.solver
17
+ handlers=consoleHandler
18
+ propagate=0
19
+
20
+ [handler_consoleHandler]
21
+ class=StreamHandler
22
+ level=INFO
23
+ formatter=simpleFormatter
24
+ args=(sys.stdout,)
25
+
26
+ [formatter_simpleFormatter]
27
+ class=uvicorn.logging.ColourizedFormatter
28
+ format={levelprefix:<8} @ {name} : {message}
29
+ style={
30
+ use_colors=True
pyproject.toml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+
6
+ [project]
7
+ name = "employee_scheduling"
8
+ version = "1.0.1"
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ 'solverforge-legacy == 1.24.1',
12
+ 'fastapi == 0.111.0',
13
+ 'pydantic == 2.7.3',
14
+ 'uvicorn == 0.30.1',
15
+ 'pytest == 8.2.2',
16
+ ]
17
+
18
+
19
+ [project.scripts]
20
+ run-app = "employee_scheduling:main"
src/employee_scheduling/__init__.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uvicorn
2
+
3
+ from .rest_api import app
4
+
5
+
6
+ def main():
7
+ config = uvicorn.Config(
8
+ "employee_scheduling:app",
9
+ host="0.0.0.0",
10
+ port=8080,
11
+ log_config="logging.conf",
12
+ use_colors=True,
13
+ )
14
+ server = uvicorn.Server(config)
15
+ server.run()
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
src/employee_scheduling/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (592 Bytes). View file
 
src/employee_scheduling/__pycache__/__init__.cpython-312.pyc ADDED
Binary file (732 Bytes). View file
 
src/employee_scheduling/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (747 Bytes). View file
 
src/employee_scheduling/__pycache__/constraints.cpython-310.pyc ADDED
Binary file (7.11 kB). View file
 
src/employee_scheduling/__pycache__/constraints.cpython-312.pyc ADDED
Binary file (14.2 kB). View file
 
src/employee_scheduling/__pycache__/constraints.cpython-313.pyc ADDED
Binary file (11.7 kB). View file
 
src/employee_scheduling/__pycache__/converters.cpython-310.pyc ADDED
Binary file (3.61 kB). View file
 
src/employee_scheduling/__pycache__/converters.cpython-312.pyc ADDED
Binary file (5.64 kB). View file
 
src/employee_scheduling/__pycache__/demo_data.cpython-310.pyc ADDED
Binary file (6.3 kB). View file
 
src/employee_scheduling/__pycache__/demo_data.cpython-312.pyc ADDED
Binary file (9.74 kB). View file
 
src/employee_scheduling/__pycache__/domain.cpython-310.pyc ADDED
Binary file (3.02 kB). View file
 
src/employee_scheduling/__pycache__/domain.cpython-312.pyc ADDED
Binary file (5.48 kB). View file
 
src/employee_scheduling/__pycache__/domain.cpython-313.pyc ADDED
Binary file (4.24 kB). View file
 
src/employee_scheduling/__pycache__/json_serialization.cpython-310.pyc ADDED
Binary file (1.22 kB). View file
 
src/employee_scheduling/__pycache__/json_serialization.cpython-312.pyc ADDED
Binary file (1.47 kB). View file
 
src/employee_scheduling/__pycache__/rest_api.cpython-310.pyc ADDED
Binary file (2.49 kB). View file
 
src/employee_scheduling/__pycache__/rest_api.cpython-312.pyc ADDED
Binary file (3.37 kB). View file
 
src/employee_scheduling/__pycache__/solver.cpython-310.pyc ADDED
Binary file (883 Bytes). View file
 
src/employee_scheduling/__pycache__/solver.cpython-312.pyc ADDED
Binary file (1.08 kB). View file
 
src/employee_scheduling/constraints.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.score import (
2
+ constraint_provider,
3
+ ConstraintFactory,
4
+ Joiners,
5
+ HardSoftDecimalScore,
6
+ ConstraintCollectors,
7
+ )
8
+ from datetime import datetime, date
9
+
10
+ from .domain import Employee, Shift
11
+
12
+
13
+ def get_minute_overlap(shift1: Shift, shift2: Shift) -> int:
14
+ return (
15
+ min(shift1.end, shift2.end) - max(shift1.start, shift2.start)
16
+ ).total_seconds() // 60
17
+
18
+
19
+ def is_overlapping_with_date(shift: Shift, dt: date) -> bool:
20
+ return shift.start.date() == dt or shift.end.date() == dt
21
+
22
+
23
+ def overlapping_in_minutes(
24
+ first_start_datetime: datetime,
25
+ first_end_datetime: datetime,
26
+ second_start_datetime: datetime,
27
+ second_end_datetime: datetime,
28
+ ) -> int:
29
+ latest_start = max(first_start_datetime, second_start_datetime)
30
+ earliest_end = min(first_end_datetime, second_end_datetime)
31
+ delta = (earliest_end - latest_start).total_seconds() / 60
32
+ return max(0, delta)
33
+
34
+
35
+ def get_shift_overlapping_duration_in_minutes(shift: Shift, dt: date) -> int:
36
+ overlap = 0
37
+ start_date_time = datetime.combine(dt, datetime.min.time())
38
+ end_date_time = datetime.combine(dt, datetime.max.time())
39
+ overlap += overlapping_in_minutes(
40
+ start_date_time, end_date_time, shift.start, shift.end
41
+ )
42
+ return overlap
43
+
44
+
45
+ @constraint_provider
46
+ def define_constraints(constraint_factory: ConstraintFactory):
47
+ return [
48
+ # Hard constraints
49
+ required_skill(constraint_factory),
50
+ no_overlapping_shifts(constraint_factory),
51
+ at_least_10_hours_between_two_shifts(constraint_factory),
52
+ one_shift_per_day(constraint_factory),
53
+ unavailable_employee(constraint_factory),
54
+ max_shifts_per_employee(constraint_factory),
55
+ # Soft constraints
56
+ undesired_day_for_employee(constraint_factory),
57
+ desired_day_for_employee(constraint_factory),
58
+ balance_employee_shift_assignments(constraint_factory),
59
+ ]
60
+
61
+
62
+ def required_skill(constraint_factory: ConstraintFactory):
63
+ return (
64
+ constraint_factory.for_each(Shift)
65
+ .filter(lambda shift: not shift.has_required_skill())
66
+ .penalize(HardSoftDecimalScore.ONE_HARD)
67
+ .as_constraint("Missing required skill")
68
+ )
69
+
70
+
71
+ def no_overlapping_shifts(constraint_factory: ConstraintFactory):
72
+ return (
73
+ constraint_factory.for_each_unique_pair(
74
+ Shift,
75
+ Joiners.equal(lambda shift: shift.employee.name),
76
+ Joiners.overlapping(lambda shift: shift.start, lambda shift: shift.end),
77
+ )
78
+ .penalize(HardSoftDecimalScore.ONE_HARD, get_minute_overlap)
79
+ .as_constraint("Overlapping shift")
80
+ )
81
+
82
+
83
+ def at_least_10_hours_between_two_shifts(constraint_factory: ConstraintFactory):
84
+ return (
85
+ constraint_factory.for_each(Shift)
86
+ .join(
87
+ Shift,
88
+ Joiners.equal(lambda shift: shift.employee.name),
89
+ Joiners.less_than_or_equal(
90
+ lambda shift: shift.end, lambda shift: shift.start
91
+ ),
92
+ )
93
+ .filter(
94
+ lambda first_shift, second_shift: (
95
+ second_shift.start - first_shift.end
96
+ ).total_seconds()
97
+ // (60 * 60)
98
+ < 10
99
+ )
100
+ .penalize(
101
+ HardSoftDecimalScore.ONE_HARD,
102
+ lambda first_shift, second_shift: 600
103
+ - ((second_shift.start - first_shift.end).total_seconds() // 60),
104
+ )
105
+ .as_constraint("At least 10 hours between 2 shifts")
106
+ )
107
+
108
+
109
+ def one_shift_per_day(constraint_factory: ConstraintFactory):
110
+ return (
111
+ constraint_factory.for_each_unique_pair(
112
+ Shift,
113
+ Joiners.equal(lambda shift: shift.employee.name),
114
+ Joiners.equal(lambda shift: shift.start.date()),
115
+ )
116
+ .penalize(HardSoftDecimalScore.ONE_HARD)
117
+ .as_constraint("Max one shift per day")
118
+ )
119
+
120
+
121
+ def unavailable_employee(constraint_factory: ConstraintFactory):
122
+ return (
123
+ constraint_factory.for_each(Shift)
124
+ .join(
125
+ Employee,
126
+ Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
127
+ )
128
+ .flatten_last(lambda employee: employee.unavailable_dates)
129
+ .filter(lambda shift, unavailable_date: shift.is_overlapping_with_date(unavailable_date))
130
+ .penalize(
131
+ HardSoftDecimalScore.ONE_HARD,
132
+ lambda shift, unavailable_date: shift.get_overlapping_duration_in_minutes(unavailable_date),
133
+ )
134
+ .as_constraint("Unavailable employee")
135
+ )
136
+
137
+
138
+ def max_shifts_per_employee(constraint_factory: ConstraintFactory):
139
+ """
140
+ Hard constraint: No employee can have more than 12 shifts.
141
+
142
+ The limit of 12 is chosen based on the demo data dimensions:
143
+ - SMALL dataset: 139 shifts / 15 employees = ~9.3 average
144
+ - This provides headroom while preventing extreme imbalance
145
+
146
+ Note: A limit that's too low (e.g., 5) would make the problem infeasible.
147
+ Always ensure your constraints are compatible with your data dimensions.
148
+ """
149
+ return (
150
+ constraint_factory.for_each(Shift)
151
+ .group_by(lambda shift: shift.employee, ConstraintCollectors.count())
152
+ .filter(lambda employee, shift_count: shift_count > 12)
153
+ .penalize(
154
+ HardSoftDecimalScore.ONE_HARD,
155
+ lambda employee, shift_count: shift_count - 12,
156
+ )
157
+ .as_constraint("Max 12 shifts per employee")
158
+ )
159
+
160
+
161
+ def undesired_day_for_employee(constraint_factory: ConstraintFactory):
162
+ return (
163
+ constraint_factory.for_each(Shift)
164
+ .join(
165
+ Employee,
166
+ Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
167
+ )
168
+ .flatten_last(lambda employee: employee.undesired_dates)
169
+ .filter(lambda shift, undesired_date: shift.is_overlapping_with_date(undesired_date))
170
+ .penalize(
171
+ HardSoftDecimalScore.ONE_SOFT,
172
+ lambda shift, undesired_date: shift.get_overlapping_duration_in_minutes(undesired_date),
173
+ )
174
+ .as_constraint("Undesired day for employee")
175
+ )
176
+
177
+
178
+ def desired_day_for_employee(constraint_factory: ConstraintFactory):
179
+ return (
180
+ constraint_factory.for_each(Shift)
181
+ .join(
182
+ Employee,
183
+ Joiners.equal(lambda shift: shift.employee, lambda employee: employee),
184
+ )
185
+ .flatten_last(lambda employee: employee.desired_dates)
186
+ .filter(lambda shift, desired_date: shift.is_overlapping_with_date(desired_date))
187
+ .reward(
188
+ HardSoftDecimalScore.ONE_SOFT,
189
+ lambda shift, desired_date: shift.get_overlapping_duration_in_minutes(desired_date),
190
+ )
191
+ .as_constraint("Desired day for employee")
192
+ )
193
+
194
+
195
+ def balance_employee_shift_assignments(constraint_factory: ConstraintFactory):
196
+ return (
197
+ constraint_factory.for_each(Shift)
198
+ .group_by(lambda shift: shift.employee, ConstraintCollectors.count())
199
+ .complement(
200
+ Employee, lambda e: 0
201
+ ) # Include all employees which are not assigned to any shift.
202
+ .group_by(
203
+ ConstraintCollectors.load_balance(
204
+ lambda employee, shift_count: employee,
205
+ lambda employee, shift_count: shift_count,
206
+ )
207
+ )
208
+ .penalize_decimal(
209
+ HardSoftDecimalScore.ONE_SOFT,
210
+ lambda load_balance: load_balance.unfairness(),
211
+ )
212
+ .as_constraint("Balance employee shift assignments")
213
+ )
src/employee_scheduling/converters.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional, Union
2
+ from datetime import datetime, date
3
+ from . import domain
4
+ from .json_serialization import JsonDomainBase
5
+ from pydantic import Field
6
+
7
+
8
+ # Conversion functions from domain to API models
9
+ def employee_to_model(employee: domain.Employee) -> domain.EmployeeModel:
10
+ return domain.EmployeeModel(
11
+ name=employee.name,
12
+ skills=list(employee.skills),
13
+ unavailable_dates=[d.isoformat() for d in employee.unavailable_dates],
14
+ undesired_dates=[d.isoformat() for d in employee.undesired_dates],
15
+ desired_dates=[d.isoformat() for d in employee.desired_dates],
16
+ )
17
+
18
+
19
+ def shift_to_model(shift: domain.Shift) -> domain.ShiftModel:
20
+ return domain.ShiftModel(
21
+ id=shift.id,
22
+ start=shift.start.isoformat(),
23
+ end=shift.end.isoformat(),
24
+ location=shift.location,
25
+ required_skill=shift.required_skill,
26
+ employee=employee_to_model(shift.employee) if shift.employee else None,
27
+ )
28
+
29
+
30
+ def schedule_to_model(
31
+ schedule: domain.EmployeeSchedule,
32
+ ) -> domain.EmployeeScheduleModel:
33
+ return domain.EmployeeScheduleModel(
34
+ employees=[employee_to_model(e) for e in schedule.employees],
35
+ shifts=[shift_to_model(s) for s in schedule.shifts],
36
+ score=str(schedule.score) if schedule.score else None,
37
+ solver_status=schedule.solver_status.name if schedule.solver_status else None,
38
+ )
39
+
40
+
41
+ # Conversion functions from API models to domain
42
+ def model_to_employee(model: domain.EmployeeModel) -> domain.Employee:
43
+ return domain.Employee(
44
+ name=model.name,
45
+ skills=set(model.skills),
46
+ unavailable_dates={date.fromisoformat(d) for d in model.unavailable_dates},
47
+ undesired_dates={date.fromisoformat(d) for d in model.undesired_dates},
48
+ desired_dates={date.fromisoformat(d) for d in model.desired_dates},
49
+ )
50
+
51
+
52
+ def model_to_shift(model: domain.ShiftModel, employee_lookup: dict) -> domain.Shift:
53
+ # Handle employee reference
54
+ employee = None
55
+ if model.employee:
56
+ if isinstance(model.employee, str):
57
+ employee = employee_lookup[model.employee]
58
+ else:
59
+ employee = model_to_employee(model.employee)
60
+
61
+ return domain.Shift(
62
+ id=model.id,
63
+ start=datetime.fromisoformat(model.start),
64
+ end=datetime.fromisoformat(model.end),
65
+ location=model.location,
66
+ required_skill=model.required_skill,
67
+ employee=employee,
68
+ )
69
+
70
+
71
+ def model_to_schedule(model: domain.EmployeeScheduleModel) -> domain.EmployeeSchedule:
72
+ # Convert employees first
73
+ employees = [model_to_employee(e) for e in model.employees]
74
+
75
+ # Create lookup dictionary for employee references
76
+ employee_lookup = {e.name: e for e in employees}
77
+
78
+ # Convert shifts with employee lookups
79
+ shifts = [model_to_shift(s, employee_lookup) for s in model.shifts]
80
+
81
+ # Handle score
82
+ score = None
83
+ if model.score:
84
+ from solverforge_legacy.solver.score import HardSoftDecimalScore
85
+
86
+ score = HardSoftDecimalScore.parse(model.score)
87
+
88
+ # Handle solver status
89
+ solver_status = domain.SolverStatus.NOT_SOLVING
90
+ if model.solver_status:
91
+ solver_status = domain.SolverStatus[model.solver_status]
92
+
93
+ return domain.EmployeeSchedule(
94
+ employees=employees, shifts=shifts, score=score, solver_status=solver_status
95
+ )
src/employee_scheduling/demo_data.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date, datetime, time, timedelta
2
+ from itertools import product
3
+ from enum import Enum
4
+ from random import Random
5
+ from typing import Generator
6
+ from dataclasses import dataclass, field
7
+
8
+ from .domain import *
9
+
10
+
11
+ class DemoData(Enum):
12
+ SMALL = 'SMALL'
13
+ LARGE = 'LARGE'
14
+
15
+
16
+ @dataclass(frozen=True, kw_only=True)
17
+ class CountDistribution:
18
+ count: int
19
+ weight: float
20
+
21
+
22
+ def counts(distributions: tuple[CountDistribution, ...]) -> tuple[int, ...]:
23
+ return tuple(distribution.count for distribution in distributions)
24
+
25
+
26
+ def weights(distributions: tuple[CountDistribution, ...]) -> tuple[float, ...]:
27
+ return tuple(distribution.weight for distribution in distributions)
28
+
29
+
30
+ @dataclass(kw_only=True)
31
+ class DemoDataParameters:
32
+ locations: tuple[str, ...]
33
+ required_skills: tuple[str, ...]
34
+ optional_skills: tuple[str, ...]
35
+ days_in_schedule: int
36
+ employee_count: int
37
+ optional_skill_distribution: tuple[CountDistribution, ...]
38
+ shift_count_distribution: tuple[CountDistribution, ...]
39
+ availability_count_distribution: tuple[CountDistribution, ...]
40
+ random_seed: int = field(default=37)
41
+
42
+
43
+ demo_data_to_parameters: dict[DemoData, DemoDataParameters] = {
44
+ DemoData.SMALL: DemoDataParameters(
45
+ locations=("Ambulatory care", "Critical care", "Pediatric care"),
46
+ required_skills=("Doctor", "Nurse"),
47
+ optional_skills=("Anaesthetics", "Cardiology"),
48
+ days_in_schedule=14,
49
+ employee_count=15,
50
+ optional_skill_distribution=(
51
+ CountDistribution(count=1, weight=3),
52
+ CountDistribution(count=2, weight=1)
53
+ ),
54
+ shift_count_distribution=(
55
+ CountDistribution(count=1, weight=0.9),
56
+ CountDistribution(count=2, weight=0.1)
57
+ ),
58
+ availability_count_distribution=(
59
+ CountDistribution(count=1, weight=4),
60
+ CountDistribution(count=2, weight=3),
61
+ CountDistribution(count=3, weight=2),
62
+ CountDistribution(count=4, weight=1)
63
+ ),
64
+ random_seed=37
65
+ ),
66
+
67
+ DemoData.LARGE: DemoDataParameters(
68
+ locations=("Ambulatory care",
69
+ "Neurology",
70
+ "Critical care",
71
+ "Pediatric care",
72
+ "Surgery",
73
+ "Radiology",
74
+ "Outpatient"),
75
+ required_skills=("Doctor", "Nurse"),
76
+ optional_skills=("Anaesthetics", "Cardiology", "Radiology"),
77
+ days_in_schedule=28,
78
+ employee_count=50,
79
+ optional_skill_distribution=(
80
+ CountDistribution(count=1, weight=3),
81
+ CountDistribution(count=2, weight=1)
82
+ ),
83
+ shift_count_distribution=(
84
+ CountDistribution(count=1, weight=0.5),
85
+ CountDistribution(count=2, weight=0.3),
86
+ CountDistribution(count=3, weight=0.2)
87
+ ),
88
+ availability_count_distribution=(
89
+ CountDistribution(count=5, weight=4),
90
+ CountDistribution(count=10, weight=3),
91
+ CountDistribution(count=15, weight=2),
92
+ CountDistribution(count=20, weight=1)
93
+ ),
94
+ random_seed=37
95
+ )
96
+ }
97
+
98
+
99
+ FIRST_NAMES = ("Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay")
100
+ LAST_NAMES = ("Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt")
101
+ SHIFT_LENGTH = timedelta(hours=8)
102
+ MORNING_SHIFT_START_TIME = time(hour=6, minute=0)
103
+ DAY_SHIFT_START_TIME = time(hour=9, minute=0)
104
+ AFTERNOON_SHIFT_START_TIME = time(hour=14, minute=0)
105
+ NIGHT_SHIFT_START_TIME = time(hour=22, minute=0)
106
+
107
+ SHIFT_START_TIMES_COMBOS = (
108
+ (MORNING_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME),
109
+ (MORNING_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME, NIGHT_SHIFT_START_TIME),
110
+ (MORNING_SHIFT_START_TIME, DAY_SHIFT_START_TIME, AFTERNOON_SHIFT_START_TIME, NIGHT_SHIFT_START_TIME),
111
+ )
112
+
113
+
114
+ location_to_shift_start_time_list_map = dict()
115
+
116
+
117
+ def earliest_monday_on_or_after(target_date: date):
118
+ """
119
+ Returns the date of the next given weekday after
120
+ the given date. For example, the date of next Monday.
121
+
122
+ NB: if it IS the day we're looking for, this returns 0.
123
+ consider then doing onDay(foo, day + 1).
124
+ """
125
+ days = (7 - target_date.weekday()) % 7
126
+ return target_date + timedelta(days=days)
127
+
128
+
129
+ def generate_demo_data(demo_data_or_parameters: DemoData | DemoDataParameters) -> EmployeeSchedule:
130
+ global location_to_shift_start_time_list_map, demo_data_to_parameters
131
+ if isinstance(demo_data_or_parameters, DemoData):
132
+ parameters = demo_data_to_parameters[demo_data_or_parameters]
133
+ else:
134
+ parameters = demo_data_or_parameters
135
+
136
+ start_date = earliest_monday_on_or_after(date.today())
137
+ random = Random(parameters.random_seed)
138
+ shift_template_index = 0
139
+ for location in parameters.locations:
140
+ location_to_shift_start_time_list_map[location] = SHIFT_START_TIMES_COMBOS[shift_template_index]
141
+ shift_template_index = (shift_template_index + 1) % len(SHIFT_START_TIMES_COMBOS)
142
+
143
+ name_permutations = [f'{first_name} {last_name}'
144
+ for first_name, last_name in product(FIRST_NAMES, LAST_NAMES)]
145
+ random.shuffle(name_permutations)
146
+
147
+ employees = []
148
+ for i in range(parameters.employee_count):
149
+ count, = random.choices(population=counts(parameters.optional_skill_distribution),
150
+ weights=weights(parameters.optional_skill_distribution))
151
+ skills = []
152
+ skills += random.sample(parameters.optional_skills, count)
153
+ skills += random.sample(parameters.required_skills, 1)
154
+ employees.append(
155
+ Employee(name=name_permutations[i],
156
+ skills=set(skills))
157
+ )
158
+
159
+ shifts: list[Shift] = []
160
+
161
+ def id_generator():
162
+ current_id = 0
163
+ while True:
164
+ yield str(current_id)
165
+ current_id += 1
166
+
167
+ ids = id_generator()
168
+
169
+ for i in range(parameters.days_in_schedule):
170
+ count, = random.choices(population=counts(parameters.availability_count_distribution),
171
+ weights=weights(parameters.availability_count_distribution))
172
+ employees_with_availabilities_on_day = random.sample(employees, count)
173
+ current_date = start_date + timedelta(days=i)
174
+ for employee in employees_with_availabilities_on_day:
175
+ rand_num = random.randint(0, 2)
176
+ if rand_num == 0:
177
+ employee.unavailable_dates.add(current_date)
178
+ elif rand_num == 1:
179
+ employee.undesired_dates.add(current_date)
180
+ elif rand_num == 2:
181
+ employee.desired_dates.add(current_date)
182
+ shifts += generate_shifts_for_day(parameters, current_date, random, ids)
183
+
184
+ shift_count = 0
185
+ for shift in shifts:
186
+ shift.id = str(shift_count)
187
+ shift_count += 1
188
+
189
+ return EmployeeSchedule(
190
+ employees=employees,
191
+ shifts=shifts
192
+ )
193
+
194
+
195
+ def generate_shifts_for_day(parameters: DemoDataParameters, current_date: date, random: Random,
196
+ ids: Generator[str, any, any]) -> list[Shift]:
197
+ global location_to_shift_start_time_list_map
198
+ shifts = []
199
+ for location in parameters.locations:
200
+ shift_start_times = location_to_shift_start_time_list_map[location]
201
+ for start_time in shift_start_times:
202
+ shift_start_date_time = datetime.combine(current_date, start_time)
203
+ shift_end_date_time = shift_start_date_time + SHIFT_LENGTH
204
+ shifts += generate_shifts_for_timeslot(parameters, shift_start_date_time, shift_end_date_time,
205
+ location, random, ids)
206
+
207
+ return shifts
208
+
209
+
210
+ def generate_shifts_for_timeslot(parameters: DemoDataParameters, timeslot_start: datetime, timeslot_end: datetime,
211
+ location: str, random: Random, ids: Generator[str, any, any]) -> list[Shift]:
212
+ shift_count, = random.choices(population=counts(parameters.shift_count_distribution),
213
+ weights=weights(parameters.shift_count_distribution))
214
+
215
+ shifts = []
216
+ for i in range(shift_count):
217
+ if random.random() >= 0.5:
218
+ required_skill = random.choice(parameters.required_skills)
219
+ else:
220
+ required_skill = random.choice(parameters.optional_skills)
221
+ shifts.append(Shift(
222
+ id=next(ids),
223
+ start=timeslot_start,
224
+ end=timeslot_end,
225
+ location=location,
226
+ required_skill=required_skill))
227
+
228
+ return shifts
src/employee_scheduling/domain.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver import SolverStatus
2
+ from solverforge_legacy.solver.domain import (
3
+ planning_entity,
4
+ planning_solution,
5
+ PlanningId,
6
+ PlanningVariable,
7
+ PlanningEntityCollectionProperty,
8
+ ProblemFactCollectionProperty,
9
+ ValueRangeProvider,
10
+ PlanningScore,
11
+ )
12
+ from solverforge_legacy.solver.score import HardSoftDecimalScore
13
+ from datetime import datetime, date
14
+ from typing import Annotated, List, Optional, Union
15
+ from dataclasses import dataclass, field
16
+ from .json_serialization import JsonDomainBase
17
+ from pydantic import Field
18
+
19
+
20
+ @dataclass
21
+ class Employee:
22
+ name: Annotated[str, PlanningId]
23
+ skills: set[str] = field(default_factory=set)
24
+ unavailable_dates: set[date] = field(default_factory=set)
25
+ undesired_dates: set[date] = field(default_factory=set)
26
+ desired_dates: set[date] = field(default_factory=set)
27
+
28
+
29
+ @planning_entity
30
+ @dataclass
31
+ class Shift:
32
+ id: Annotated[str, PlanningId]
33
+ start: datetime
34
+ end: datetime
35
+ location: str
36
+ required_skill: str
37
+ employee: Annotated[Employee | None, PlanningVariable] = None
38
+
39
+ def has_required_skill(self) -> bool:
40
+ """Check if assigned employee has the required skill."""
41
+ if self.employee is None:
42
+ return False
43
+ return self.required_skill in self.employee.skills
44
+
45
+ def is_overlapping_with_date(self, dt: date) -> bool:
46
+ """Check if shift overlaps with a specific date."""
47
+ return self.start.date() == dt or self.end.date() == dt
48
+
49
+ def get_overlapping_duration_in_minutes(self, dt: date) -> int:
50
+ """Calculate overlap duration in minutes for a specific date."""
51
+ start_date_time = datetime.combine(dt, datetime.min.time())
52
+ end_date_time = datetime.combine(dt, datetime.max.time())
53
+
54
+ # Calculate overlap between date range and shift range
55
+ max_start_time = max(start_date_time, self.start)
56
+ min_end_time = min(end_date_time, self.end)
57
+
58
+ minutes = (min_end_time - max_start_time).total_seconds() / 60
59
+ return int(max(0, minutes))
60
+
61
+
62
+ @planning_solution
63
+ @dataclass
64
+ class EmployeeSchedule:
65
+ employees: Annotated[
66
+ list[Employee], ProblemFactCollectionProperty, ValueRangeProvider
67
+ ]
68
+ shifts: Annotated[list[Shift], PlanningEntityCollectionProperty]
69
+ score: Annotated[HardSoftDecimalScore | None, PlanningScore] = None
70
+ solver_status: SolverStatus = SolverStatus.NOT_SOLVING
71
+
72
+
73
+ # Pydantic REST models for API (used for deserialization and context)
74
+ class EmployeeModel(JsonDomainBase):
75
+ name: str
76
+ skills: List[str] = Field(default_factory=list)
77
+ unavailable_dates: List[str] = Field(default_factory=list, alias="unavailableDates")
78
+ undesired_dates: List[str] = Field(default_factory=list, alias="undesiredDates")
79
+ desired_dates: List[str] = Field(default_factory=list, alias="desiredDates")
80
+
81
+
82
+ class ShiftModel(JsonDomainBase):
83
+ id: str
84
+ start: str # ISO datetime string
85
+ end: str # ISO datetime string
86
+ location: str
87
+ required_skill: str = Field(..., alias="requiredSkill")
88
+ employee: Union[str, EmployeeModel, None] = None
89
+
90
+
91
+ class EmployeeScheduleModel(JsonDomainBase):
92
+ employees: List[EmployeeModel]
93
+ shifts: List[ShiftModel]
94
+ score: Optional[str] = None
95
+ solver_status: Optional[str] = None
src/employee_scheduling/json_serialization.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver.score import HardSoftDecimalScore
2
+ from typing import Any
3
+ from pydantic import BaseModel, ConfigDict, PlainSerializer, BeforeValidator
4
+ from pydantic.alias_generators import to_camel
5
+
6
+ ScoreSerializer = PlainSerializer(
7
+ lambda score: str(score) if score is not None else None, return_type=str | None
8
+ )
9
+
10
+
11
+ def validate_score(v: Any) -> Any:
12
+ if isinstance(v, HardSoftDecimalScore) or v is None:
13
+ return v
14
+ if isinstance(v, str):
15
+ return HardSoftDecimalScore.parse(v)
16
+ raise ValueError('"score" should be a string')
17
+
18
+
19
+ ScoreValidator = BeforeValidator(validate_score)
20
+
21
+
22
+ class JsonDomainBase(BaseModel):
23
+ model_config = ConfigDict(
24
+ alias_generator=to_camel,
25
+ populate_by_name=True,
26
+ from_attributes=True,
27
+ )
src/employee_scheduling/rest_api.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.staticfiles import StaticFiles
3
+ from uuid import uuid4
4
+ from dataclasses import replace
5
+
6
+ from .domain import EmployeeSchedule, EmployeeScheduleModel
7
+ from .converters import (
8
+ schedule_to_model, model_to_schedule
9
+ )
10
+ from .demo_data import DemoData, generate_demo_data
11
+ from .solver import solver_manager, solution_manager
12
+
13
+ app = FastAPI(docs_url='/q/swagger-ui')
14
+ data_sets: dict[str, EmployeeSchedule] = {}
15
+
16
+
17
+ @app.get("/demo-data")
18
+ async def demo_data_list() -> list[DemoData]:
19
+ return [e for e in DemoData]
20
+
21
+
22
+ @app.get("/demo-data/{dataset_id}", response_model_exclude_none=True)
23
+ async def get_demo_data(dataset_id: str) -> EmployeeScheduleModel:
24
+ demo_data = getattr(DemoData, dataset_id)
25
+ domain_schedule = generate_demo_data(demo_data)
26
+ return schedule_to_model(domain_schedule)
27
+
28
+
29
+ @app.get("/schedules/{problem_id}", response_model_exclude_none=True)
30
+ async def get_timetable(problem_id: str) -> EmployeeScheduleModel:
31
+ schedule = data_sets[problem_id]
32
+ updated_schedule = replace(schedule, solver_status=solver_manager.get_solver_status(problem_id))
33
+ return schedule_to_model(updated_schedule)
34
+
35
+
36
+ def update_schedule(problem_id: str, schedule: EmployeeSchedule):
37
+ global data_sets
38
+ data_sets[problem_id] = schedule
39
+
40
+
41
+ @app.post("/schedules")
42
+ async def solve_timetable(schedule_model: EmployeeScheduleModel) -> str:
43
+ job_id = str(uuid4())
44
+ schedule = model_to_schedule(schedule_model)
45
+ data_sets[job_id] = schedule
46
+ solver_manager.solve_and_listen(job_id, schedule,
47
+ lambda solution: update_schedule(job_id, solution))
48
+ return job_id
49
+
50
+
51
+ @app.delete("/schedules/{problem_id}")
52
+ async def stop_solving(problem_id: str) -> None:
53
+ solver_manager.terminate_early(problem_id)
54
+
55
+
56
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
src/employee_scheduling/solver.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from solverforge_legacy.solver import SolverManager, SolverFactory, SolutionManager
2
+ from solverforge_legacy.solver.config import (
3
+ SolverConfig,
4
+ ScoreDirectorFactoryConfig,
5
+ TerminationConfig,
6
+ Duration,
7
+ )
8
+
9
+ from .domain import EmployeeSchedule, Shift
10
+ from .constraints import define_constraints
11
+
12
+
13
+ solver_config = SolverConfig(
14
+ solution_class=EmployeeSchedule,
15
+ entity_class_list=[Shift],
16
+ score_director_factory_config=ScoreDirectorFactoryConfig(
17
+ constraint_provider_function=define_constraints
18
+ ),
19
+ termination_config=TerminationConfig(spent_limit=Duration(seconds=30)),
20
+ )
21
+
22
+ solver_manager = SolverManager.create(SolverFactory.create(solver_config))
23
+ solution_manager = SolutionManager.create(solver_manager)
static/app.js ADDED
@@ -0,0 +1,520 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let autoRefreshIntervalId = null;
2
+ const zoomMin = 2 * 1000 * 60 * 60 * 24 // 2 day in milliseconds
3
+ const zoomMax = 4 * 7 * 1000 * 60 * 60 * 24 // 4 weeks in milliseconds
4
+
5
+ const UNAVAILABLE_COLOR = '#ef2929' // Tango Scarlet Red
6
+ const UNDESIRED_COLOR = '#f57900' // Tango Orange
7
+ const DESIRED_COLOR = '#73d216' // Tango Chameleon
8
+
9
+ let demoDataId = null;
10
+ let scheduleId = null;
11
+ let loadedSchedule = null;
12
+
13
+ const byEmployeePanel = document.getElementById("byEmployeePanel");
14
+ const byEmployeeTimelineOptions = {
15
+ timeAxis: {scale: "hour", step: 6},
16
+ orientation: {axis: "top"},
17
+ stack: false,
18
+ xss: {disabled: true}, // Items are XSS safe through JQuery
19
+ zoomMin: zoomMin,
20
+ zoomMax: zoomMax,
21
+ };
22
+ let byEmployeeGroupDataSet = new vis.DataSet();
23
+ let byEmployeeItemDataSet = new vis.DataSet();
24
+ let byEmployeeTimeline = new vis.Timeline(byEmployeePanel, byEmployeeItemDataSet, byEmployeeGroupDataSet, byEmployeeTimelineOptions);
25
+
26
+ const byLocationPanel = document.getElementById("byLocationPanel");
27
+ const byLocationTimelineOptions = {
28
+ timeAxis: {scale: "hour", step: 6},
29
+ orientation: {axis: "top"},
30
+ xss: {disabled: true}, // Items are XSS safe through JQuery
31
+ zoomMin: zoomMin,
32
+ zoomMax: zoomMax,
33
+ };
34
+ let byLocationGroupDataSet = new vis.DataSet();
35
+ let byLocationItemDataSet = new vis.DataSet();
36
+ let byLocationTimeline = new vis.Timeline(byLocationPanel, byLocationItemDataSet, byLocationGroupDataSet, byLocationTimelineOptions);
37
+
38
+ let windowStart = JSJoda.LocalDate.now().toString();
39
+ let windowEnd = JSJoda.LocalDate.parse(windowStart).plusDays(7).toString();
40
+
41
+ $(document).ready(function () {
42
+ let initialized = false;
43
+
44
+ function safeInitialize() {
45
+ if (!initialized) {
46
+ initialized = true;
47
+ initializeApp();
48
+ }
49
+ }
50
+
51
+ // Ensure all resources are loaded before initializing
52
+ $(window).on('load', safeInitialize);
53
+
54
+ // Fallback if window load event doesn't fire
55
+ setTimeout(safeInitialize, 100);
56
+ });
57
+
58
+ function initializeApp() {
59
+ replaceQuickstartSolverForgeAutoHeaderFooter();
60
+
61
+ $("#solveButton").click(function () {
62
+ solve();
63
+ });
64
+ $("#stopSolvingButton").click(function () {
65
+ stopSolving();
66
+ });
67
+ $("#analyzeButton").click(function () {
68
+ analyze();
69
+ });
70
+ // HACK to allow vis-timeline to work within Bootstrap tabs
71
+ $("#byEmployeeTab").on('shown.bs.tab', function (event) {
72
+ byEmployeeTimeline.redraw();
73
+ })
74
+ $("#byLocationTab").on('shown.bs.tab', function (event) {
75
+ byLocationTimeline.redraw();
76
+ })
77
+
78
+ setupAjax();
79
+ fetchDemoData();
80
+ }
81
+
82
+ function setupAjax() {
83
+ $.ajaxSetup({
84
+ headers: {
85
+ 'Content-Type': 'application/json',
86
+ 'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job
87
+ }
88
+ });
89
+ // Extend jQuery to support $.put() and $.delete()
90
+ jQuery.each(["put", "delete"], function (i, method) {
91
+ jQuery[method] = function (url, data, callback, type) {
92
+ if (jQuery.isFunction(data)) {
93
+ type = type || callback;
94
+ callback = data;
95
+ data = undefined;
96
+ }
97
+ return jQuery.ajax({
98
+ url: url,
99
+ type: method,
100
+ dataType: type,
101
+ data: data,
102
+ success: callback
103
+ });
104
+ };
105
+ });
106
+ }
107
+
108
+ function fetchDemoData() {
109
+ $.get("/demo-data", function (data) {
110
+ data.forEach(item => {
111
+ $("#testDataButton").append($('<a id="' + item + 'TestData" class="dropdown-item" href="#">' + item + '</a>'));
112
+ $("#" + item + "TestData").click(function () {
113
+ switchDataDropDownItemActive(item);
114
+ scheduleId = null;
115
+ demoDataId = item;
116
+
117
+ refreshSchedule();
118
+ });
119
+ });
120
+ demoDataId = data[0];
121
+ switchDataDropDownItemActive(demoDataId);
122
+ refreshSchedule();
123
+ }).fail(function (xhr, ajaxOptions, thrownError) {
124
+ // disable this page as there is no data
125
+ let $demo = $("#demo");
126
+ $demo.empty();
127
+ $demo.html("<h1><p align=\"center\">No test data available</p></h1>")
128
+ });
129
+ }
130
+
131
+ function switchDataDropDownItemActive(newItem) {
132
+ activeCssClass = "active";
133
+ $("#testDataButton > a." + activeCssClass).removeClass(activeCssClass);
134
+ $("#" + newItem + "TestData").addClass(activeCssClass);
135
+ }
136
+
137
+ function getShiftColor(shift, employee) {
138
+ const shiftStart = JSJoda.LocalDateTime.parse(shift.start);
139
+ const shiftStartDateString = shiftStart.toLocalDate().toString();
140
+ const shiftEnd = JSJoda.LocalDateTime.parse(shift.end);
141
+ const shiftEndDateString = shiftEnd.toLocalDate().toString();
142
+ if (employee.unavailableDates.includes(shiftStartDateString) ||
143
+ // The contains() check is ignored for a shift end at midnight (00:00:00).
144
+ (shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
145
+ employee.unavailableDates.includes(shiftEndDateString))) {
146
+ return UNAVAILABLE_COLOR
147
+ } else if (employee.undesiredDates.includes(shiftStartDateString) ||
148
+ // The contains() check is ignored for a shift end at midnight (00:00:00).
149
+ (shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
150
+ employee.undesiredDates.includes(shiftEndDateString))) {
151
+ return UNDESIRED_COLOR
152
+ } else if (employee.desiredDates.includes(shiftStartDateString) ||
153
+ // The contains() check is ignored for a shift end at midnight (00:00:00).
154
+ (shiftEnd.isAfter(shiftStart.toLocalDate().plusDays(1).atStartOfDay()) &&
155
+ employee.desiredDates.includes(shiftEndDateString))) {
156
+ return DESIRED_COLOR
157
+ } else {
158
+ return " #729fcf"; // Tango Sky Blue
159
+ }
160
+ }
161
+
162
+ function refreshSchedule() {
163
+ let path = "/schedules/" + scheduleId;
164
+ if (scheduleId === null) {
165
+ if (demoDataId === null) {
166
+ alert("Please select a test data set.");
167
+ return;
168
+ }
169
+
170
+ path = "/demo-data/" + demoDataId;
171
+ }
172
+ $.getJSON(path, function (schedule) {
173
+ loadedSchedule = schedule;
174
+ renderSchedule(schedule);
175
+ })
176
+ .fail(function (xhr, ajaxOptions, thrownError) {
177
+ showError("Getting the schedule has failed.", xhr);
178
+ refreshSolvingButtons(false);
179
+ });
180
+ }
181
+
182
+ function renderSchedule(schedule) {
183
+ console.log('Rendering schedule:', schedule);
184
+
185
+ if (!schedule) {
186
+ console.error('No schedule data provided to renderSchedule');
187
+ return;
188
+ }
189
+
190
+ refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING");
191
+ $("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score));
192
+
193
+ const unassignedShifts = $("#unassignedShifts");
194
+ const groups = [];
195
+
196
+ // Check if schedule.shifts exists and is an array
197
+ if (!schedule.shifts || !Array.isArray(schedule.shifts) || schedule.shifts.length === 0) {
198
+ console.warn('No shifts data available in schedule');
199
+ return;
200
+ }
201
+
202
+ // Show only first 7 days of draft
203
+ const scheduleStart = schedule.shifts.map(shift => JSJoda.LocalDateTime.parse(shift.start).toLocalDate()).sort()[0].toString();
204
+ const scheduleEnd = JSJoda.LocalDate.parse(scheduleStart).plusDays(7).toString();
205
+
206
+ windowStart = scheduleStart;
207
+ windowEnd = scheduleEnd;
208
+
209
+ unassignedShifts.children().remove();
210
+ let unassignedShiftsCount = 0;
211
+ byEmployeeGroupDataSet.clear();
212
+ byLocationGroupDataSet.clear();
213
+
214
+ byEmployeeItemDataSet.clear();
215
+ byLocationItemDataSet.clear();
216
+
217
+ // Check if schedule.employees exists and is an array
218
+ if (!schedule.employees || !Array.isArray(schedule.employees)) {
219
+ console.warn('No employees data available in schedule');
220
+ return;
221
+ }
222
+
223
+ schedule.employees.forEach((employee, index) => {
224
+ const employeeGroupElement = $('<div class="card-body p-2"/>')
225
+ .append($(`<h5 class="card-title mb-2"/>)`)
226
+ .append(employee.name))
227
+ .append($('<div/>')
228
+ .append($(employee.skills.map(skill => `<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${skill}</span>`).join(''))));
229
+ byEmployeeGroupDataSet.add({id: employee.name, content: employeeGroupElement.html()});
230
+
231
+ employee.unavailableDates.forEach((rawDate, dateIndex) => {
232
+ const date = JSJoda.LocalDate.parse(rawDate)
233
+ const start = date.atStartOfDay().toString();
234
+ const end = date.plusDays(1).atStartOfDay().toString();
235
+ const byEmployeeShiftElement = $(`<div/>`)
236
+ .append($(`<h5 class="card-title mb-1"/>`).text("Unavailable"));
237
+ byEmployeeItemDataSet.add({
238
+ id: "employee-" + index + "-unavailability-" + dateIndex, group: employee.name,
239
+ content: byEmployeeShiftElement.html(),
240
+ start: start, end: end,
241
+ type: "background",
242
+ style: "opacity: 0.5; background-color: " + UNAVAILABLE_COLOR,
243
+ });
244
+ });
245
+ employee.undesiredDates.forEach((rawDate, dateIndex) => {
246
+ const date = JSJoda.LocalDate.parse(rawDate)
247
+ const start = date.atStartOfDay().toString();
248
+ const end = date.plusDays(1).atStartOfDay().toString();
249
+ const byEmployeeShiftElement = $(`<div/>`)
250
+ .append($(`<h5 class="card-title mb-1"/>`).text("Undesired"));
251
+ byEmployeeItemDataSet.add({
252
+ id: "employee-" + index + "-undesired-" + dateIndex, group: employee.name,
253
+ content: byEmployeeShiftElement.html(),
254
+ start: start, end: end,
255
+ type: "background",
256
+ style: "opacity: 0.5; background-color: " + UNDESIRED_COLOR,
257
+ });
258
+ });
259
+ employee.desiredDates.forEach((rawDate, dateIndex) => {
260
+ const date = JSJoda.LocalDate.parse(rawDate)
261
+ const start = date.atStartOfDay().toString();
262
+ const end = date.plusDays(1).atStartOfDay().toString();
263
+ const byEmployeeShiftElement = $(`<div/>`)
264
+ .append($(`<h5 class="card-title mb-1"/>`).text("Desired"));
265
+ byEmployeeItemDataSet.add({
266
+ id: "employee-" + index + "-desired-" + dateIndex, group: employee.name,
267
+ content: byEmployeeShiftElement.html(),
268
+ start: start, end: end,
269
+ type: "background",
270
+ style: "opacity: 0.5; background-color: " + DESIRED_COLOR,
271
+ });
272
+ });
273
+ });
274
+
275
+ schedule.shifts.forEach((shift, index) => {
276
+ if (groups.indexOf(shift.location) === -1) {
277
+ groups.push(shift.location);
278
+ byLocationGroupDataSet.add({
279
+ id: shift.location,
280
+ content: shift.location,
281
+ });
282
+ }
283
+
284
+ if (shift.employee == null) {
285
+ unassignedShiftsCount++;
286
+
287
+ const byLocationShiftElement = $('<div class="card-body p-2"/>')
288
+ .append($(`<h5 class="card-title mb-2"/>)`)
289
+ .append("Unassigned"))
290
+ .append($('<div/>')
291
+ .append($(`<span class="badge me-1 mt-1" style="background-color:#d3d7cf">${shift.requiredSkill}</span>`)));
292
+
293
+ byLocationItemDataSet.add({
294
+ id: 'shift-' + index, group: shift.location,
295
+ content: byLocationShiftElement.html(),
296
+ start: shift.start, end: shift.end,
297
+ style: "background-color: #EF292999"
298
+ });
299
+ } else {
300
+ const skillColor = (shift.employee.skills.indexOf(shift.requiredSkill) === -1 ? '#ef2929' : '#8ae234');
301
+ const byEmployeeShiftElement = $('<div class="card-body p-2"/>')
302
+ .append($(`<h5 class="card-title mb-2"/>)`)
303
+ .append(shift.location))
304
+ .append($('<div/>')
305
+ .append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
306
+ const byLocationShiftElement = $('<div class="card-body p-2"/>')
307
+ .append($(`<h5 class="card-title mb-2"/>)`)
308
+ .append(shift.employee.name))
309
+ .append($('<div/>')
310
+ .append($(`<span class="badge me-1 mt-1" style="background-color:${skillColor}">${shift.requiredSkill}</span>`)));
311
+
312
+ const shiftColor = getShiftColor(shift, shift.employee);
313
+ byEmployeeItemDataSet.add({
314
+ id: 'shift-' + index, group: shift.employee.name,
315
+ content: byEmployeeShiftElement.html(),
316
+ start: shift.start, end: shift.end,
317
+ style: "background-color: " + shiftColor
318
+ });
319
+ byLocationItemDataSet.add({
320
+ id: 'shift-' + index, group: shift.location,
321
+ content: byLocationShiftElement.html(),
322
+ start: shift.start, end: shift.end,
323
+ style: "background-color: " + shiftColor
324
+ });
325
+ }
326
+ });
327
+
328
+
329
+ if (unassignedShiftsCount === 0) {
330
+ unassignedShifts.append($(`<p/>`).text(`There are no unassigned shifts.`));
331
+ } else {
332
+ unassignedShifts.append($(`<p/>`).text(`There are ${unassignedShiftsCount} unassigned shifts.`));
333
+ }
334
+ byEmployeeTimeline.setWindow(scheduleStart, scheduleEnd);
335
+ byLocationTimeline.setWindow(scheduleStart, scheduleEnd);
336
+ }
337
+
338
+ function solve() {
339
+ if (!loadedSchedule) {
340
+ showError("No schedule data loaded. Please wait for the data to load or refresh the page.");
341
+ return;
342
+ }
343
+
344
+ console.log('Sending schedule data for solving:', loadedSchedule);
345
+ $.post("/schedules", JSON.stringify(loadedSchedule), function (data) {
346
+ scheduleId = data;
347
+ refreshSolvingButtons(true);
348
+ }).fail(function (xhr, ajaxOptions, thrownError) {
349
+ showError("Start solving failed.", xhr);
350
+ refreshSolvingButtons(false);
351
+ },
352
+ "text");
353
+ }
354
+
355
+ function analyze() {
356
+ new bootstrap.Modal("#scoreAnalysisModal").show()
357
+ const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
358
+ scoreAnalysisModalContent.children().remove();
359
+ if (loadedSchedule.score == null) {
360
+ scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button.");
361
+ } else {
362
+ $('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
363
+ $.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) {
364
+ let constraints = scoreAnalysis.constraints;
365
+ constraints.sort((a, b) => {
366
+ let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
367
+ if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
368
+ if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
369
+ if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
370
+ return -1;
371
+ } else {
372
+ if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
373
+ if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
374
+ if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
375
+ return -1;
376
+ } else {
377
+ if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
378
+ if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
379
+
380
+ return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
381
+ }
382
+ }
383
+ });
384
+ constraints.map((e) => {
385
+ let components = getScoreComponents(e.weight);
386
+ e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
387
+ e.weight = components[e.type];
388
+ let scores = getScoreComponents(e.score);
389
+ e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
390
+ });
391
+ scoreAnalysis.constraints = constraints;
392
+
393
+ scoreAnalysisModalContent.children().remove();
394
+ scoreAnalysisModalContent.text("");
395
+
396
+ const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
397
+ const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
398
+ .append($(`<th></th>`))
399
+ .append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
400
+ .append($(`<th>Type</th>`))
401
+ .append($(`<th># Matches</th>`))
402
+ .append($(`<th>Weight</th>`))
403
+ .append($(`<th>Score</th>`))
404
+ .append($(`<th></th>`)));
405
+ analysisTable.append(analysisTHead);
406
+ const analysisTBody = $(`<tbody/>`)
407
+ $.each(scoreAnalysis.constraints, (index, constraintAnalysis) => {
408
+ let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
409
+ if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
410
+
411
+ let row = $(`<tr/>`);
412
+ row.append($(`<td/>`).html(icon))
413
+ .append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
414
+ .append($(`<td/>`).text(constraintAnalysis.type))
415
+ .append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
416
+ .append($(`<td/>`).text(constraintAnalysis.weight))
417
+ .append($(`<td/>`).text(constraintAnalysis.implicitScore));
418
+ analysisTBody.append(row);
419
+ row.append($(`<td/>`));
420
+ });
421
+ analysisTable.append(analysisTBody);
422
+ scoreAnalysisModalContent.append(analysisTable);
423
+ }).fail(function (xhr, ajaxOptions, thrownError) {
424
+ showError("Analyze failed.", xhr);
425
+ }, "text");
426
+ }
427
+ }
428
+
429
+ function getScoreComponents(score) {
430
+ let components = {hard: 0, medium: 0, soft: 0};
431
+
432
+ $.each([...score.matchAll(/(-?\d*(\.\d+)?)(hard|medium|soft)/g)], (i, parts) => {
433
+ components[parts[3]] = parseFloat(parts[1], 10);
434
+ });
435
+
436
+ return components;
437
+ }
438
+
439
+ function refreshSolvingButtons(solving) {
440
+ if (solving) {
441
+ $("#solveButton").hide();
442
+ $("#stopSolvingButton").show();
443
+ if (autoRefreshIntervalId == null) {
444
+ autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
445
+ }
446
+ } else {
447
+ $("#solveButton").show();
448
+ $("#stopSolvingButton").hide();
449
+ if (autoRefreshIntervalId != null) {
450
+ clearInterval(autoRefreshIntervalId);
451
+ autoRefreshIntervalId = null;
452
+ }
453
+ }
454
+ }
455
+
456
+ function stopSolving() {
457
+ $.delete(`/schedules/${scheduleId}`, function () {
458
+ refreshSolvingButtons(false);
459
+ refreshSchedule();
460
+ }).fail(function (xhr, ajaxOptions, thrownError) {
461
+ showError("Stop solving failed.", xhr);
462
+ });
463
+ }
464
+
465
+ function replaceQuickstartSolverForgeAutoHeaderFooter() {
466
+ const solverforgeHeader = $("header#solverforge-auto-header");
467
+ if (solverforgeHeader != null) {
468
+ solverforgeHeader.css("background-color", "#ffffff");
469
+ solverforgeHeader.append(
470
+ $(`<div class="container-fluid">
471
+ <nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
472
+ <a class="navbar-brand" href="https://www.solverforge.org">
473
+ <img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400">
474
+ </a>
475
+ <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
476
+ <span class="navbar-toggler-icon"></span>
477
+ </button>
478
+ <div class="collapse navbar-collapse" id="navbarNav">
479
+ <ul class="nav nav-pills">
480
+ <li class="nav-item active" id="navUIItem">
481
+ <button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">Demo UI</button>
482
+ </li>
483
+ <li class="nav-item" id="navRestItem">
484
+ <button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">Guide</button>
485
+ </li>
486
+ <li class="nav-item" id="navOpenApiItem">
487
+ <button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">REST API</button>
488
+ </li>
489
+ </ul>
490
+ </div>
491
+ <div class="ms-auto">
492
+ <div class="dropdown">
493
+ <button class="btn dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" style="background-color: #10b981; color: #ffffff; border-color: #10b981;">
494
+ Data
495
+ </button>
496
+ <div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
497
+ </div>
498
+ </div>
499
+ </nav>
500
+ </div>`));
501
+ }
502
+
503
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
504
+ if (solverforgeFooter != null) {
505
+ solverforgeFooter.append(
506
+ $(`<footer class="bg-black text-white-50">
507
+ <div class="container">
508
+ <div class="hstack gap-3 p-4">
509
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
510
+ <div class="vr"></div>
511
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
512
+ <div class="vr"></div>
513
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
514
+ <div class="vr"></div>
515
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
516
+ </div>
517
+ </div>
518
+ </footer>`));
519
+ }
520
+ }
static/index.html ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html lang="en">
2
+ <head>
3
+ <meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
4
+ <meta content="width=device-width, initial-scale=1" name="viewport">
5
+ <title>Employee scheduling - SolverForge for Python</title>
6
+
7
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
8
+ integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"/>
10
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"/>
11
+ <link rel="stylesheet" href="/webjars/solverforge/css/solverforge-webui.css"/>
12
+ <style>
13
+ .vis-time-axis .vis-grid.vis-saturday,
14
+ .vis-time-axis .vis-grid.vis-sunday {
15
+ background: #D3D7CFFF;
16
+ }
17
+ </style>
18
+ <link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
19
+ </head>
20
+
21
+ <body>
22
+ <header id="solverforge-auto-header">
23
+ <!-- Filled in by app.js -->
24
+ </header>
25
+ <div class="tab-content">
26
+ <div id="demo" class="tab-pane fade show active container-fluid">
27
+ <div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite"
28
+ aria-atomic="true">
29
+ <div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
30
+ </div>
31
+ <h1>Employee scheduling solver</h1>
32
+ <p>Generate the optimal schedule for your employees.</p>
33
+
34
+ <div class="mb-4">
35
+ <button id="solveButton" type="button" class="btn btn-success">
36
+ <span class="fas fa-play"></span> Solve
37
+ </button>
38
+ <button id="stopSolvingButton" type="button" class="btn btn-danger">
39
+ <span class="fas fa-stop"></span> Stop solving
40
+ </button>
41
+ <span id="unassignedShifts" class="ms-2 align-middle fw-bold"></span>
42
+ <span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
43
+
44
+ <div class="float-end">
45
+ <ul class="nav nav-pills" role="tablist">
46
+ <li class="nav-item" role="presentation">
47
+ <button class="nav-link active" id="byLocationTab" data-bs-toggle="tab"
48
+ data-bs-target="#byLocationPanel" type="button" role="tab"
49
+ aria-controls="byLocationPanel" aria-selected="true">By location
50
+ </button>
51
+ </li>
52
+ <li class="nav-item" role="presentation">
53
+ <button class="nav-link" id="byEmployeeTab" data-bs-toggle="tab"
54
+ data-bs-target="#byEmployeePanel" type="button" role="tab"
55
+ aria-controls="byEmployeePanel" aria-selected="false">By employee
56
+ </button>
57
+ </li>
58
+ </ul>
59
+ </div>
60
+ </div>
61
+ <div class="mb-4 tab-content">
62
+ <div class="tab-pane fade show active" id="byLocationPanel" role="tabpanel"
63
+ aria-labelledby="byLocationTab">
64
+ <div id="locationVisualization"></div>
65
+ </div>
66
+ <div class="tab-pane fade" id="byEmployeePanel" role="tabpanel" aria-labelledby="byEmployeeTab">
67
+ <div id="employeeVisualization"></div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <div id="rest" class="tab-pane fade container-fluid">
73
+ <h1>REST API Guide</h1>
74
+
75
+ <h2>Employee Scheduling solver integration via cURL</h2>
76
+
77
+ <h3>1. Download demo data</h3>
78
+ <pre>
79
+ <button class="btn btn-outline-dark btn-sm float-end"
80
+ onclick="copyTextToClipboard('curl1')">Copy</button>
81
+ <code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data/SMALL -o sample.json</code>
82
+ </pre>
83
+
84
+ <h3>2. Post the sample data for solving</h3>
85
+ <p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
86
+ <pre>
87
+ <button class="btn btn-outline-dark btn-sm float-end"
88
+ onclick="copyTextToClipboard('curl2')">Copy</button>
89
+ <code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json</code>
90
+ </pre>
91
+
92
+ <h3>3. Get the current status and score</h3>
93
+ <pre>
94
+ <button class="btn btn-outline-dark btn-sm float-end"
95
+ onclick="copyTextToClipboard('curl3')">Copy</button>
96
+ <code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status</code>
97
+ </pre>
98
+
99
+ <h3>4. Get the complete solution</h3>
100
+ <pre>
101
+ <button class="btn btn-outline-dark btn-sm float-end"
102
+ onclick="copyTextToClipboard('curl4')">Copy</button>
103
+ <code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}</code>
104
+ </pre>
105
+
106
+ <h3>5. Terminate solving early</h3>
107
+ <pre>
108
+ <button class="btn btn-outline-dark btn-sm float-end"
109
+ onclick="copyTextToClipboard('curl5')">Copy</button>
110
+ <code id="curl5">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{id}</code>
111
+ </pre>
112
+ </div>
113
+
114
+ <div id="openapi" class="tab-pane fade container-fluid">
115
+ <h1>REST API Reference</h1>
116
+ <div class="ratio ratio-1x1">
117
+ <!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
118
+ <iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
119
+ </div>
120
+ </div>
121
+ </div>
122
+ <footer id="solverforge-auto-footer"></footer>
123
+
124
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
125
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js"></script>
126
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
127
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/js-joda/1.11.0/js-joda.min.js"></script>
128
+ <script src="/webjars/solverforge/js/solverforge-webui.js"></script>
129
+ <script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
130
+ integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
131
+ <script src="/app.js"></script>
132
+ </body>
133
+ </html>
static/webjars/solverforge/css/solverforge-webui.css ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Keep in sync with .navbar height on a large screen. */
3
+ --ts-navbar-height: 109px;
4
+
5
+ --ts-green-1-rgb: #10b981;
6
+ --ts-green-2-rgb: #059669;
7
+ --ts-violet-1-rgb: #3E00FF;
8
+ --ts-violet-2-rgb: #3423A6;
9
+ --ts-violet-3-rgb: #2E1760;
10
+ --ts-violet-4-rgb: #200F4F;
11
+ --ts-violet-5-rgb: #000000; /* TODO FIXME */
12
+ --ts-violet-dark-1-rgb: #b6adfd;
13
+ --ts-violet-dark-2-rgb: #c1bbfd;
14
+ --ts-gray-rgb: #666666;
15
+ --ts-white-rgb: #FFFFFF;
16
+ --ts-light-rgb: #F2F2F2;
17
+ --ts-gray-border: #c5c5c5;
18
+
19
+ --tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
20
+ --bs-body-bg: var(--ts-light-rgb); /* link to html bg */
21
+ --bs-link-color: var(--ts-violet-1-rgb);
22
+ --bs-link-hover-color: var(--ts-violet-2-rgb);
23
+
24
+ --bs-navbar-color: var(--ts-white-rgb);
25
+ --bs-navbar-hover-color: var(--ts-white-rgb);
26
+ --bs-nav-link-font-size: 18px;
27
+ --bs-nav-link-font-weight: 400;
28
+ --bs-nav-link-color: var(--ts-white-rgb);
29
+ --ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
30
+ }
31
+ .btn {
32
+ --bs-btn-border-radius: 1.5rem;
33
+ }
34
+ .btn-primary {
35
+ --bs-btn-bg: var(--ts-violet-1-rgb);
36
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
37
+ --bs-btn-hover-bg: var(--ts-violet-2-rgb);
38
+ --bs-btn-hover-border-color: var(--ts-violet-2-rgb);
39
+ --bs-btn-active-bg: var(--ts-violet-2-rgb);
40
+ --bs-btn-active-border-bg: var(--ts-violet-2-rgb);
41
+ --bs-btn-disabled-bg: var(--ts-violet-1-rgb);
42
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
43
+ }
44
+ .btn-outline-primary {
45
+ --bs-btn-color: var(--ts-violet-1-rgb);
46
+ --bs-btn-border-color: var(--ts-violet-1-rgb);
47
+ --bs-btn-hover-bg: var(--ts-violet-1-rgb);
48
+ --bs-btn-hover-border-color: var(--ts-violet-1-rgb);
49
+ --bs-btn-active-bg: var(--ts-violet-1-rgb);
50
+ --bs-btn-active-border-color: var(--ts-violet-1-rgb);
51
+ --bs-btn-disabled-color: var(--ts-violet-1-rgb);
52
+ --bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
53
+ }
54
+ .navbar-dark {
55
+ --bs-link-color: var(--ts-violet-dark-1-rgb);
56
+ --bs-link-hover-color: var(--ts-violet-dark-2-rgb);
57
+ --bs-navbar-color: var(--ts-white-rgb);
58
+ --bs-navbar-hover-color: var(--ts-white-rgb);
59
+ }
60
+ .nav-pills {
61
+ --bs-nav-pills-link-active-bg: var(--ts-green-1-rgb);
62
+ }
63
+ .nav-pills .nav-link:hover {
64
+ color: var(--ts-green-1-rgb);
65
+ }
66
+ .nav-pills .nav-link.active:hover {
67
+ color: var(--ts-white-rgb);
68
+ }
static/webjars/solverforge/img/solverforge-favicon.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal-white.svg ADDED
static/webjars/solverforge/img/solverforge-horizontal.svg ADDED
static/webjars/solverforge/img/solverforge-logo-stacked.svg ADDED
static/webjars/solverforge/js/solverforge-webui.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ function replaceSolverForgeAutoHeaderFooter() {
2
+ const solverforgeHeader = $("header#solverforge-auto-header");
3
+ if (solverforgeHeader != null) {
4
+ solverforgeHeader.addClass("bg-black")
5
+ solverforgeHeader.append(
6
+ $(`<div class="container-fluid">
7
+ <nav class="navbar sticky-top navbar-expand-lg navbar-dark shadow mb-3">
8
+ <a class="navbar-brand" href="https://www.solverforge.org">
9
+ <img src="/solverforge/img/solverforge-horizontal-white.svg" alt="SolverForge logo" width="200">
10
+ </a>
11
+ </nav>
12
+ </div>`));
13
+ }
14
+ const solverforgeFooter = $("footer#solverforge-auto-footer");
15
+ if (solverforgeFooter != null) {
16
+ solverforgeFooter.append(
17
+ $(`<footer class="bg-black text-white-50">
18
+ <div class="container">
19
+ <div class="hstack gap-3 p-4">
20
+ <div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
21
+ <div class="vr"></div>
22
+ <div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
23
+ <div class="vr"></div>
24
+ <div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
25
+ <div class="vr"></div>
26
+ <div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
27
+ </div>
28
+ </div>
29
+ <div id="applicationInfo" class="container text-center"></div>
30
+ </footer>`));
31
+
32
+ applicationInfo();
33
+ }
34
+
35
+ }
36
+
37
+ function showSimpleError(title) {
38
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
39
+ .append($(`<div class="toast-header bg-danger">
40
+ <strong class="me-auto text-dark">Error</strong>
41
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
42
+ </div>`))
43
+ .append($(`<div class="toast-body"/>`)
44
+ .append($(`<p/>`).text(title))
45
+ );
46
+ $("#notificationPanel").append(notification);
47
+ notification.toast({delay: 30000});
48
+ notification.toast('show');
49
+ }
50
+
51
+ function showError(title, xhr) {
52
+ var serverErrorMessage = !xhr.responseJSON ? `${xhr.status}: ${xhr.statusText}` : xhr.responseJSON.message;
53
+ var serverErrorCode = !xhr.responseJSON ? `unknown` : xhr.responseJSON.code;
54
+ var serverErrorId = !xhr.responseJSON ? `----` : xhr.responseJSON.id;
55
+ var serverErrorDetails = !xhr.responseJSON ? `no details provided` : xhr.responseJSON.details;
56
+
57
+ if (xhr.responseJSON && !serverErrorMessage) {
58
+ serverErrorMessage = JSON.stringify(xhr.responseJSON);
59
+ serverErrorCode = xhr.statusText + '(' + xhr.status + ')';
60
+ serverErrorId = `----`;
61
+ }
62
+
63
+ console.error(title + "\n" + serverErrorMessage + " : " + serverErrorDetails);
64
+ const notification = $(`<div class="toast" role="alert" aria-live="assertive" aria-atomic="true" style="min-width: 50rem"/>`)
65
+ .append($(`<div class="toast-header bg-danger">
66
+ <strong class="me-auto text-dark">Error</strong>
67
+ <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
68
+ </div>`))
69
+ .append($(`<div class="toast-body"/>`)
70
+ .append($(`<p/>`).text(title))
71
+ .append($(`<pre/>`)
72
+ .append($(`<code/>`).text(serverErrorMessage + "\n\nCode: " + serverErrorCode + "\nError id: " + serverErrorId))
73
+ )
74
+ );
75
+ $("#notificationPanel").append(notification);
76
+ notification.toast({delay: 30000});
77
+ notification.toast('show');
78
+ }
79
+
80
+ // ****************************************************************************
81
+ // Application info
82
+ // ****************************************************************************
83
+
84
+ function applicationInfo() {
85
+ $.getJSON("info", function (info) {
86
+ $("#applicationInfo").append("<small>" + info.application + " (version: " + info.version + ", built at: " + info.built + ")</small>");
87
+ }).fail(function (xhr, ajaxOptions, thrownError) {
88
+ console.warn("Unable to collect application information");
89
+ });
90
+ }
91
+
92
+ // ****************************************************************************
93
+ // TangoColorFactory
94
+ // ****************************************************************************
95
+
96
+ const SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8];
97
+ const SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B];
98
+
99
+ var colorMap = new Map;
100
+ var nextColorCount = 0;
101
+
102
+ function pickColor(object) {
103
+ let color = colorMap[object];
104
+ if (color !== undefined) {
105
+ return color;
106
+ }
107
+ color = nextColor();
108
+ colorMap[object] = color;
109
+ return color;
110
+ }
111
+
112
+ function nextColor() {
113
+ let color;
114
+ let colorIndex = nextColorCount % SEQUENCE_1.length;
115
+ let shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length);
116
+ if (shadeIndex === 0) {
117
+ color = SEQUENCE_1[colorIndex];
118
+ } else if (shadeIndex === 1) {
119
+ color = SEQUENCE_2[colorIndex];
120
+ } else {
121
+ shadeIndex -= 3;
122
+ let floorColor = SEQUENCE_2[colorIndex];
123
+ let ceilColor = SEQUENCE_1[colorIndex];
124
+ let base = Math.floor((shadeIndex / 2) + 1);
125
+ let divisor = 2;
126
+ while (base >= divisor) {
127
+ divisor *= 2;
128
+ }
129
+ base = (base * 2) - divisor + 1;
130
+ let shadePercentage = base / divisor;
131
+ color = buildPercentageColor(floorColor, ceilColor, shadePercentage);
132
+ }
133
+ nextColorCount++;
134
+ return "#" + color.toString(16);
135
+ }
136
+
137
+ function buildPercentageColor(floorColor, ceilColor, shadePercentage) {
138
+ let red = (floorColor & 0xFF0000) + Math.floor(shadePercentage * ((ceilColor & 0xFF0000) - (floorColor & 0xFF0000))) & 0xFF0000;
139
+ let green = (floorColor & 0x00FF00) + Math.floor(shadePercentage * ((ceilColor & 0x00FF00) - (floorColor & 0x00FF00))) & 0x00FF00;
140
+ let blue = (floorColor & 0x0000FF) + Math.floor(shadePercentage * ((ceilColor & 0x0000FF) - (floorColor & 0x0000FF))) & 0x0000FF;
141
+ return red | green | blue;
142
+ }
tests/__pycache__/test_constraints.cpython-310-pytest-8.2.2.pyc ADDED
Binary file (5.55 kB). View file
 
tests/__pycache__/test_constraints.cpython-311-pytest-8.2.2.pyc ADDED
Binary file (16.6 kB). View file
 
tests/__pycache__/test_constraints.cpython-312-pytest-8.2.2.pyc ADDED
Binary file (15 kB). View file
 
tests/__pycache__/test_feasible.cpython-310-pytest-8.2.2.pyc ADDED
Binary file (2.28 kB). View file
 
tests/__pycache__/test_feasible.cpython-311-pytest-8.2.2.pyc ADDED
Binary file (4.65 kB). View file