Upload 31 files
Browse files- Dockerfile +24 -0
- README.md +82 -5
- input.json +1978 -0
- logging.conf +28 -0
- pyproject.toml +19 -0
- src/meeting_scheduling/__init__.py +13 -0
- src/meeting_scheduling/constraints.py +527 -0
- src/meeting_scheduling/converters.py +325 -0
- src/meeting_scheduling/demo_data.py +225 -0
- src/meeting_scheduling/domain.py +247 -0
- src/meeting_scheduling/json_serialization.py +55 -0
- src/meeting_scheduling/rest_api.py +163 -0
- src/meeting_scheduling/score_analysis.py +17 -0
- src/meeting_scheduling/solver.py +22 -0
- static/app.js +1023 -0
- static/index.html +177 -0
- static/webjars/solverforge/css/solverforge-webui.css +68 -0
- static/webjars/solverforge/img/solverforge-favicon.svg +65 -0
- static/webjars/solverforge/img/solverforge-horizontal-white.svg +66 -0
- static/webjars/solverforge/img/solverforge-horizontal.svg +65 -0
- static/webjars/solverforge/img/solverforge-logo-stacked.svg +73 -0
- static/webjars/solverforge/js/solverforge-webui.js +142 -0
- static/webjars/timefold/css/timefold-webui.css +60 -0
- static/webjars/timefold/img/timefold-favicon.svg +25 -0
- static/webjars/timefold/img/timefold-logo-horizontal-negative.svg +1 -0
- static/webjars/timefold/img/timefold-logo-horizontal-positive.svg +1 -0
- static/webjars/timefold/img/timefold-logo-stacked-positive.svg +1 -0
- static/webjars/timefold/js/timefold-webui.js +142 -0
- tests/__init__.py +1 -0
- tests/test_constraints.py +562 -0
- tests/test_feasible.py +143 -0
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,89 @@
|
|
| 1 |
---
|
| 2 |
-
title: Meeting Scheduling Python
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: apache-2.0
|
| 9 |
short_description: SolverForge Quickstart for the Meeting Scheduling problem
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Meeting 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 Meeting Scheduling problem
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# Meeting Scheduling (Python)
|
| 14 |
+
|
| 15 |
+
Schedule meetings between employees, where each meeting has a topic, duration, required and preferred attendees.
|
| 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-quickstarts repo and navigate to this directory:
|
| 34 |
+
|
| 35 |
+
```sh
|
| 36 |
+
$ git clone https://github.com/SolverForge/solverforge-quickstarts.git
|
| 37 |
+
...
|
| 38 |
+
$ cd solverforge-quickstarts/fast/meeting-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 |
+
## Problem Description
|
| 78 |
+
|
| 79 |
+
Schedule meetings between employees, where:
|
| 80 |
+
|
| 81 |
+
* Each meeting has a topic, duration, required and preferred attendees.
|
| 82 |
+
* Each meeting needs a room with sufficient capacity.
|
| 83 |
+
* Meetings should not overlap with other meetings if they share resources (room or attendees).
|
| 84 |
+
* Meetings should be scheduled as soon as possible.
|
| 85 |
+
* Preferred attendees should be able to attend if possible.
|
| 86 |
+
|
| 87 |
+
## More information
|
| 88 |
+
|
| 89 |
+
Visit [solverforge.org](https://www.solverforge.org).
|
input.json
ADDED
|
@@ -0,0 +1,1978 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"people": [
|
| 3 |
+
{
|
| 4 |
+
"id": "0",
|
| 5 |
+
"fullName": "Flo Green"
|
| 6 |
+
},
|
| 7 |
+
{
|
| 8 |
+
"id": "1",
|
| 9 |
+
"fullName": "Jay Carr"
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
"id": "2",
|
| 13 |
+
"fullName": "Amy Smith"
|
| 14 |
+
},
|
| 15 |
+
{
|
| 16 |
+
"id": "3",
|
| 17 |
+
"fullName": "Jay Green"
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"id": "4",
|
| 21 |
+
"fullName": "Avis Carr"
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"id": "5",
|
| 25 |
+
"fullName": "Lino Howe"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"id": "6",
|
| 29 |
+
"fullName": "Lino Cole"
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"id": "7",
|
| 33 |
+
"fullName": "Hugo Howe"
|
| 34 |
+
},
|
| 35 |
+
{
|
| 36 |
+
"id": "8",
|
| 37 |
+
"fullName": "Russ Wise"
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"id": "9",
|
| 41 |
+
"fullName": "Hugo Jones"
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"id": "10",
|
| 45 |
+
"fullName": "Dino Green"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"id": "11",
|
| 49 |
+
"fullName": "Lyle Poe"
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"id": "12",
|
| 53 |
+
"fullName": "Nick Li"
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"id": "13",
|
| 57 |
+
"fullName": "Josh Clay"
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"id": "14",
|
| 61 |
+
"fullName": "Josh Smith"
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"id": "15",
|
| 65 |
+
"fullName": "Flo Cole"
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
"id": "16",
|
| 69 |
+
"fullName": "Lino Green"
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"id": "17",
|
| 73 |
+
"fullName": "Flo Poe"
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"id": "18",
|
| 77 |
+
"fullName": "Josh Jones"
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"id": "19",
|
| 81 |
+
"fullName": "Beth Horn"
|
| 82 |
+
}
|
| 83 |
+
],
|
| 84 |
+
"timeGrains": [
|
| 85 |
+
{
|
| 86 |
+
"id": "1",
|
| 87 |
+
"grainIndex": 1,
|
| 88 |
+
"dayOfYear": 175,
|
| 89 |
+
"startingMinuteOfDay": 480
|
| 90 |
+
},
|
| 91 |
+
{
|
| 92 |
+
"id": "2",
|
| 93 |
+
"grainIndex": 2,
|
| 94 |
+
"dayOfYear": 175,
|
| 95 |
+
"startingMinuteOfDay": 495
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"id": "3",
|
| 99 |
+
"grainIndex": 3,
|
| 100 |
+
"dayOfYear": 175,
|
| 101 |
+
"startingMinuteOfDay": 510
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"id": "4",
|
| 105 |
+
"grainIndex": 4,
|
| 106 |
+
"dayOfYear": 175,
|
| 107 |
+
"startingMinuteOfDay": 525
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"id": "5",
|
| 111 |
+
"grainIndex": 5,
|
| 112 |
+
"dayOfYear": 175,
|
| 113 |
+
"startingMinuteOfDay": 540
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"id": "6",
|
| 117 |
+
"grainIndex": 6,
|
| 118 |
+
"dayOfYear": 175,
|
| 119 |
+
"startingMinuteOfDay": 555
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"id": "7",
|
| 123 |
+
"grainIndex": 7,
|
| 124 |
+
"dayOfYear": 175,
|
| 125 |
+
"startingMinuteOfDay": 570
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"id": "8",
|
| 129 |
+
"grainIndex": 8,
|
| 130 |
+
"dayOfYear": 175,
|
| 131 |
+
"startingMinuteOfDay": 585
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"id": "9",
|
| 135 |
+
"grainIndex": 9,
|
| 136 |
+
"dayOfYear": 175,
|
| 137 |
+
"startingMinuteOfDay": 600
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
"id": "10",
|
| 141 |
+
"grainIndex": 10,
|
| 142 |
+
"dayOfYear": 175,
|
| 143 |
+
"startingMinuteOfDay": 615
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"id": "11",
|
| 147 |
+
"grainIndex": 11,
|
| 148 |
+
"dayOfYear": 175,
|
| 149 |
+
"startingMinuteOfDay": 630
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"id": "12",
|
| 153 |
+
"grainIndex": 12,
|
| 154 |
+
"dayOfYear": 175,
|
| 155 |
+
"startingMinuteOfDay": 645
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"id": "13",
|
| 159 |
+
"grainIndex": 13,
|
| 160 |
+
"dayOfYear": 175,
|
| 161 |
+
"startingMinuteOfDay": 660
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"id": "14",
|
| 165 |
+
"grainIndex": 14,
|
| 166 |
+
"dayOfYear": 175,
|
| 167 |
+
"startingMinuteOfDay": 675
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
"id": "15",
|
| 171 |
+
"grainIndex": 15,
|
| 172 |
+
"dayOfYear": 175,
|
| 173 |
+
"startingMinuteOfDay": 690
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
"id": "16",
|
| 177 |
+
"grainIndex": 16,
|
| 178 |
+
"dayOfYear": 175,
|
| 179 |
+
"startingMinuteOfDay": 705
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"id": "17",
|
| 183 |
+
"grainIndex": 17,
|
| 184 |
+
"dayOfYear": 175,
|
| 185 |
+
"startingMinuteOfDay": 720
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"id": "18",
|
| 189 |
+
"grainIndex": 18,
|
| 190 |
+
"dayOfYear": 175,
|
| 191 |
+
"startingMinuteOfDay": 735
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
"id": "19",
|
| 195 |
+
"grainIndex": 19,
|
| 196 |
+
"dayOfYear": 175,
|
| 197 |
+
"startingMinuteOfDay": 750
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"id": "20",
|
| 201 |
+
"grainIndex": 20,
|
| 202 |
+
"dayOfYear": 175,
|
| 203 |
+
"startingMinuteOfDay": 765
|
| 204 |
+
},
|
| 205 |
+
{
|
| 206 |
+
"id": "21",
|
| 207 |
+
"grainIndex": 21,
|
| 208 |
+
"dayOfYear": 175,
|
| 209 |
+
"startingMinuteOfDay": 780
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
"id": "22",
|
| 213 |
+
"grainIndex": 22,
|
| 214 |
+
"dayOfYear": 175,
|
| 215 |
+
"startingMinuteOfDay": 795
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"id": "23",
|
| 219 |
+
"grainIndex": 23,
|
| 220 |
+
"dayOfYear": 175,
|
| 221 |
+
"startingMinuteOfDay": 810
|
| 222 |
+
},
|
| 223 |
+
{
|
| 224 |
+
"id": "24",
|
| 225 |
+
"grainIndex": 24,
|
| 226 |
+
"dayOfYear": 175,
|
| 227 |
+
"startingMinuteOfDay": 825
|
| 228 |
+
},
|
| 229 |
+
{
|
| 230 |
+
"id": "25",
|
| 231 |
+
"grainIndex": 25,
|
| 232 |
+
"dayOfYear": 175,
|
| 233 |
+
"startingMinuteOfDay": 840
|
| 234 |
+
},
|
| 235 |
+
{
|
| 236 |
+
"id": "26",
|
| 237 |
+
"grainIndex": 26,
|
| 238 |
+
"dayOfYear": 175,
|
| 239 |
+
"startingMinuteOfDay": 855
|
| 240 |
+
},
|
| 241 |
+
{
|
| 242 |
+
"id": "27",
|
| 243 |
+
"grainIndex": 27,
|
| 244 |
+
"dayOfYear": 175,
|
| 245 |
+
"startingMinuteOfDay": 870
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
"id": "28",
|
| 249 |
+
"grainIndex": 28,
|
| 250 |
+
"dayOfYear": 175,
|
| 251 |
+
"startingMinuteOfDay": 885
|
| 252 |
+
},
|
| 253 |
+
{
|
| 254 |
+
"id": "29",
|
| 255 |
+
"grainIndex": 29,
|
| 256 |
+
"dayOfYear": 175,
|
| 257 |
+
"startingMinuteOfDay": 900
|
| 258 |
+
},
|
| 259 |
+
{
|
| 260 |
+
"id": "30",
|
| 261 |
+
"grainIndex": 30,
|
| 262 |
+
"dayOfYear": 175,
|
| 263 |
+
"startingMinuteOfDay": 915
|
| 264 |
+
},
|
| 265 |
+
{
|
| 266 |
+
"id": "31",
|
| 267 |
+
"grainIndex": 31,
|
| 268 |
+
"dayOfYear": 175,
|
| 269 |
+
"startingMinuteOfDay": 930
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"id": "32",
|
| 273 |
+
"grainIndex": 32,
|
| 274 |
+
"dayOfYear": 175,
|
| 275 |
+
"startingMinuteOfDay": 945
|
| 276 |
+
},
|
| 277 |
+
{
|
| 278 |
+
"id": "33",
|
| 279 |
+
"grainIndex": 33,
|
| 280 |
+
"dayOfYear": 175,
|
| 281 |
+
"startingMinuteOfDay": 960
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
"id": "34",
|
| 285 |
+
"grainIndex": 34,
|
| 286 |
+
"dayOfYear": 175,
|
| 287 |
+
"startingMinuteOfDay": 975
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"id": "35",
|
| 291 |
+
"grainIndex": 35,
|
| 292 |
+
"dayOfYear": 175,
|
| 293 |
+
"startingMinuteOfDay": 990
|
| 294 |
+
},
|
| 295 |
+
{
|
| 296 |
+
"id": "36",
|
| 297 |
+
"grainIndex": 36,
|
| 298 |
+
"dayOfYear": 175,
|
| 299 |
+
"startingMinuteOfDay": 1005
|
| 300 |
+
},
|
| 301 |
+
{
|
| 302 |
+
"id": "37",
|
| 303 |
+
"grainIndex": 37,
|
| 304 |
+
"dayOfYear": 175,
|
| 305 |
+
"startingMinuteOfDay": 1020
|
| 306 |
+
},
|
| 307 |
+
{
|
| 308 |
+
"id": "38",
|
| 309 |
+
"grainIndex": 38,
|
| 310 |
+
"dayOfYear": 175,
|
| 311 |
+
"startingMinuteOfDay": 1035
|
| 312 |
+
},
|
| 313 |
+
{
|
| 314 |
+
"id": "39",
|
| 315 |
+
"grainIndex": 39,
|
| 316 |
+
"dayOfYear": 175,
|
| 317 |
+
"startingMinuteOfDay": 1050
|
| 318 |
+
},
|
| 319 |
+
{
|
| 320 |
+
"id": "40",
|
| 321 |
+
"grainIndex": 40,
|
| 322 |
+
"dayOfYear": 175,
|
| 323 |
+
"startingMinuteOfDay": 1065
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"id": "41",
|
| 327 |
+
"grainIndex": 41,
|
| 328 |
+
"dayOfYear": 176,
|
| 329 |
+
"startingMinuteOfDay": 480
|
| 330 |
+
},
|
| 331 |
+
{
|
| 332 |
+
"id": "42",
|
| 333 |
+
"grainIndex": 42,
|
| 334 |
+
"dayOfYear": 176,
|
| 335 |
+
"startingMinuteOfDay": 495
|
| 336 |
+
},
|
| 337 |
+
{
|
| 338 |
+
"id": "43",
|
| 339 |
+
"grainIndex": 43,
|
| 340 |
+
"dayOfYear": 176,
|
| 341 |
+
"startingMinuteOfDay": 510
|
| 342 |
+
},
|
| 343 |
+
{
|
| 344 |
+
"id": "44",
|
| 345 |
+
"grainIndex": 44,
|
| 346 |
+
"dayOfYear": 176,
|
| 347 |
+
"startingMinuteOfDay": 525
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"id": "45",
|
| 351 |
+
"grainIndex": 45,
|
| 352 |
+
"dayOfYear": 176,
|
| 353 |
+
"startingMinuteOfDay": 540
|
| 354 |
+
},
|
| 355 |
+
{
|
| 356 |
+
"id": "46",
|
| 357 |
+
"grainIndex": 46,
|
| 358 |
+
"dayOfYear": 176,
|
| 359 |
+
"startingMinuteOfDay": 555
|
| 360 |
+
},
|
| 361 |
+
{
|
| 362 |
+
"id": "47",
|
| 363 |
+
"grainIndex": 47,
|
| 364 |
+
"dayOfYear": 176,
|
| 365 |
+
"startingMinuteOfDay": 570
|
| 366 |
+
},
|
| 367 |
+
{
|
| 368 |
+
"id": "48",
|
| 369 |
+
"grainIndex": 48,
|
| 370 |
+
"dayOfYear": 176,
|
| 371 |
+
"startingMinuteOfDay": 585
|
| 372 |
+
},
|
| 373 |
+
{
|
| 374 |
+
"id": "49",
|
| 375 |
+
"grainIndex": 49,
|
| 376 |
+
"dayOfYear": 176,
|
| 377 |
+
"startingMinuteOfDay": 600
|
| 378 |
+
},
|
| 379 |
+
{
|
| 380 |
+
"id": "50",
|
| 381 |
+
"grainIndex": 50,
|
| 382 |
+
"dayOfYear": 176,
|
| 383 |
+
"startingMinuteOfDay": 615
|
| 384 |
+
},
|
| 385 |
+
{
|
| 386 |
+
"id": "51",
|
| 387 |
+
"grainIndex": 51,
|
| 388 |
+
"dayOfYear": 176,
|
| 389 |
+
"startingMinuteOfDay": 630
|
| 390 |
+
},
|
| 391 |
+
{
|
| 392 |
+
"id": "52",
|
| 393 |
+
"grainIndex": 52,
|
| 394 |
+
"dayOfYear": 176,
|
| 395 |
+
"startingMinuteOfDay": 645
|
| 396 |
+
},
|
| 397 |
+
{
|
| 398 |
+
"id": "53",
|
| 399 |
+
"grainIndex": 53,
|
| 400 |
+
"dayOfYear": 176,
|
| 401 |
+
"startingMinuteOfDay": 660
|
| 402 |
+
},
|
| 403 |
+
{
|
| 404 |
+
"id": "54",
|
| 405 |
+
"grainIndex": 54,
|
| 406 |
+
"dayOfYear": 176,
|
| 407 |
+
"startingMinuteOfDay": 675
|
| 408 |
+
},
|
| 409 |
+
{
|
| 410 |
+
"id": "55",
|
| 411 |
+
"grainIndex": 55,
|
| 412 |
+
"dayOfYear": 176,
|
| 413 |
+
"startingMinuteOfDay": 690
|
| 414 |
+
},
|
| 415 |
+
{
|
| 416 |
+
"id": "56",
|
| 417 |
+
"grainIndex": 56,
|
| 418 |
+
"dayOfYear": 176,
|
| 419 |
+
"startingMinuteOfDay": 705
|
| 420 |
+
},
|
| 421 |
+
{
|
| 422 |
+
"id": "57",
|
| 423 |
+
"grainIndex": 57,
|
| 424 |
+
"dayOfYear": 176,
|
| 425 |
+
"startingMinuteOfDay": 720
|
| 426 |
+
},
|
| 427 |
+
{
|
| 428 |
+
"id": "58",
|
| 429 |
+
"grainIndex": 58,
|
| 430 |
+
"dayOfYear": 176,
|
| 431 |
+
"startingMinuteOfDay": 735
|
| 432 |
+
},
|
| 433 |
+
{
|
| 434 |
+
"id": "59",
|
| 435 |
+
"grainIndex": 59,
|
| 436 |
+
"dayOfYear": 176,
|
| 437 |
+
"startingMinuteOfDay": 750
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
"id": "60",
|
| 441 |
+
"grainIndex": 60,
|
| 442 |
+
"dayOfYear": 176,
|
| 443 |
+
"startingMinuteOfDay": 765
|
| 444 |
+
},
|
| 445 |
+
{
|
| 446 |
+
"id": "61",
|
| 447 |
+
"grainIndex": 61,
|
| 448 |
+
"dayOfYear": 176,
|
| 449 |
+
"startingMinuteOfDay": 780
|
| 450 |
+
},
|
| 451 |
+
{
|
| 452 |
+
"id": "62",
|
| 453 |
+
"grainIndex": 62,
|
| 454 |
+
"dayOfYear": 176,
|
| 455 |
+
"startingMinuteOfDay": 795
|
| 456 |
+
},
|
| 457 |
+
{
|
| 458 |
+
"id": "63",
|
| 459 |
+
"grainIndex": 63,
|
| 460 |
+
"dayOfYear": 176,
|
| 461 |
+
"startingMinuteOfDay": 810
|
| 462 |
+
},
|
| 463 |
+
{
|
| 464 |
+
"id": "64",
|
| 465 |
+
"grainIndex": 64,
|
| 466 |
+
"dayOfYear": 176,
|
| 467 |
+
"startingMinuteOfDay": 825
|
| 468 |
+
},
|
| 469 |
+
{
|
| 470 |
+
"id": "65",
|
| 471 |
+
"grainIndex": 65,
|
| 472 |
+
"dayOfYear": 176,
|
| 473 |
+
"startingMinuteOfDay": 840
|
| 474 |
+
},
|
| 475 |
+
{
|
| 476 |
+
"id": "66",
|
| 477 |
+
"grainIndex": 66,
|
| 478 |
+
"dayOfYear": 176,
|
| 479 |
+
"startingMinuteOfDay": 855
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"id": "67",
|
| 483 |
+
"grainIndex": 67,
|
| 484 |
+
"dayOfYear": 176,
|
| 485 |
+
"startingMinuteOfDay": 870
|
| 486 |
+
},
|
| 487 |
+
{
|
| 488 |
+
"id": "68",
|
| 489 |
+
"grainIndex": 68,
|
| 490 |
+
"dayOfYear": 176,
|
| 491 |
+
"startingMinuteOfDay": 885
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"id": "69",
|
| 495 |
+
"grainIndex": 69,
|
| 496 |
+
"dayOfYear": 176,
|
| 497 |
+
"startingMinuteOfDay": 900
|
| 498 |
+
},
|
| 499 |
+
{
|
| 500 |
+
"id": "70",
|
| 501 |
+
"grainIndex": 70,
|
| 502 |
+
"dayOfYear": 176,
|
| 503 |
+
"startingMinuteOfDay": 915
|
| 504 |
+
},
|
| 505 |
+
{
|
| 506 |
+
"id": "71",
|
| 507 |
+
"grainIndex": 71,
|
| 508 |
+
"dayOfYear": 176,
|
| 509 |
+
"startingMinuteOfDay": 930
|
| 510 |
+
},
|
| 511 |
+
{
|
| 512 |
+
"id": "72",
|
| 513 |
+
"grainIndex": 72,
|
| 514 |
+
"dayOfYear": 176,
|
| 515 |
+
"startingMinuteOfDay": 945
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
"id": "73",
|
| 519 |
+
"grainIndex": 73,
|
| 520 |
+
"dayOfYear": 176,
|
| 521 |
+
"startingMinuteOfDay": 960
|
| 522 |
+
},
|
| 523 |
+
{
|
| 524 |
+
"id": "74",
|
| 525 |
+
"grainIndex": 74,
|
| 526 |
+
"dayOfYear": 176,
|
| 527 |
+
"startingMinuteOfDay": 975
|
| 528 |
+
},
|
| 529 |
+
{
|
| 530 |
+
"id": "75",
|
| 531 |
+
"grainIndex": 75,
|
| 532 |
+
"dayOfYear": 176,
|
| 533 |
+
"startingMinuteOfDay": 990
|
| 534 |
+
},
|
| 535 |
+
{
|
| 536 |
+
"id": "76",
|
| 537 |
+
"grainIndex": 76,
|
| 538 |
+
"dayOfYear": 176,
|
| 539 |
+
"startingMinuteOfDay": 1005
|
| 540 |
+
},
|
| 541 |
+
{
|
| 542 |
+
"id": "77",
|
| 543 |
+
"grainIndex": 77,
|
| 544 |
+
"dayOfYear": 176,
|
| 545 |
+
"startingMinuteOfDay": 1020
|
| 546 |
+
},
|
| 547 |
+
{
|
| 548 |
+
"id": "78",
|
| 549 |
+
"grainIndex": 78,
|
| 550 |
+
"dayOfYear": 176,
|
| 551 |
+
"startingMinuteOfDay": 1035
|
| 552 |
+
},
|
| 553 |
+
{
|
| 554 |
+
"id": "79",
|
| 555 |
+
"grainIndex": 79,
|
| 556 |
+
"dayOfYear": 176,
|
| 557 |
+
"startingMinuteOfDay": 1050
|
| 558 |
+
},
|
| 559 |
+
{
|
| 560 |
+
"id": "80",
|
| 561 |
+
"grainIndex": 80,
|
| 562 |
+
"dayOfYear": 176,
|
| 563 |
+
"startingMinuteOfDay": 1065
|
| 564 |
+
},
|
| 565 |
+
{
|
| 566 |
+
"id": "81",
|
| 567 |
+
"grainIndex": 81,
|
| 568 |
+
"dayOfYear": 177,
|
| 569 |
+
"startingMinuteOfDay": 480
|
| 570 |
+
},
|
| 571 |
+
{
|
| 572 |
+
"id": "82",
|
| 573 |
+
"grainIndex": 82,
|
| 574 |
+
"dayOfYear": 177,
|
| 575 |
+
"startingMinuteOfDay": 495
|
| 576 |
+
},
|
| 577 |
+
{
|
| 578 |
+
"id": "83",
|
| 579 |
+
"grainIndex": 83,
|
| 580 |
+
"dayOfYear": 177,
|
| 581 |
+
"startingMinuteOfDay": 510
|
| 582 |
+
},
|
| 583 |
+
{
|
| 584 |
+
"id": "84",
|
| 585 |
+
"grainIndex": 84,
|
| 586 |
+
"dayOfYear": 177,
|
| 587 |
+
"startingMinuteOfDay": 525
|
| 588 |
+
},
|
| 589 |
+
{
|
| 590 |
+
"id": "85",
|
| 591 |
+
"grainIndex": 85,
|
| 592 |
+
"dayOfYear": 177,
|
| 593 |
+
"startingMinuteOfDay": 540
|
| 594 |
+
},
|
| 595 |
+
{
|
| 596 |
+
"id": "86",
|
| 597 |
+
"grainIndex": 86,
|
| 598 |
+
"dayOfYear": 177,
|
| 599 |
+
"startingMinuteOfDay": 555
|
| 600 |
+
},
|
| 601 |
+
{
|
| 602 |
+
"id": "87",
|
| 603 |
+
"grainIndex": 87,
|
| 604 |
+
"dayOfYear": 177,
|
| 605 |
+
"startingMinuteOfDay": 570
|
| 606 |
+
},
|
| 607 |
+
{
|
| 608 |
+
"id": "88",
|
| 609 |
+
"grainIndex": 88,
|
| 610 |
+
"dayOfYear": 177,
|
| 611 |
+
"startingMinuteOfDay": 585
|
| 612 |
+
},
|
| 613 |
+
{
|
| 614 |
+
"id": "89",
|
| 615 |
+
"grainIndex": 89,
|
| 616 |
+
"dayOfYear": 177,
|
| 617 |
+
"startingMinuteOfDay": 600
|
| 618 |
+
},
|
| 619 |
+
{
|
| 620 |
+
"id": "90",
|
| 621 |
+
"grainIndex": 90,
|
| 622 |
+
"dayOfYear": 177,
|
| 623 |
+
"startingMinuteOfDay": 615
|
| 624 |
+
},
|
| 625 |
+
{
|
| 626 |
+
"id": "91",
|
| 627 |
+
"grainIndex": 91,
|
| 628 |
+
"dayOfYear": 177,
|
| 629 |
+
"startingMinuteOfDay": 630
|
| 630 |
+
},
|
| 631 |
+
{
|
| 632 |
+
"id": "92",
|
| 633 |
+
"grainIndex": 92,
|
| 634 |
+
"dayOfYear": 177,
|
| 635 |
+
"startingMinuteOfDay": 645
|
| 636 |
+
},
|
| 637 |
+
{
|
| 638 |
+
"id": "93",
|
| 639 |
+
"grainIndex": 93,
|
| 640 |
+
"dayOfYear": 177,
|
| 641 |
+
"startingMinuteOfDay": 660
|
| 642 |
+
},
|
| 643 |
+
{
|
| 644 |
+
"id": "94",
|
| 645 |
+
"grainIndex": 94,
|
| 646 |
+
"dayOfYear": 177,
|
| 647 |
+
"startingMinuteOfDay": 675
|
| 648 |
+
},
|
| 649 |
+
{
|
| 650 |
+
"id": "95",
|
| 651 |
+
"grainIndex": 95,
|
| 652 |
+
"dayOfYear": 177,
|
| 653 |
+
"startingMinuteOfDay": 690
|
| 654 |
+
},
|
| 655 |
+
{
|
| 656 |
+
"id": "96",
|
| 657 |
+
"grainIndex": 96,
|
| 658 |
+
"dayOfYear": 177,
|
| 659 |
+
"startingMinuteOfDay": 705
|
| 660 |
+
},
|
| 661 |
+
{
|
| 662 |
+
"id": "97",
|
| 663 |
+
"grainIndex": 97,
|
| 664 |
+
"dayOfYear": 177,
|
| 665 |
+
"startingMinuteOfDay": 720
|
| 666 |
+
},
|
| 667 |
+
{
|
| 668 |
+
"id": "98",
|
| 669 |
+
"grainIndex": 98,
|
| 670 |
+
"dayOfYear": 177,
|
| 671 |
+
"startingMinuteOfDay": 735
|
| 672 |
+
},
|
| 673 |
+
{
|
| 674 |
+
"id": "99",
|
| 675 |
+
"grainIndex": 99,
|
| 676 |
+
"dayOfYear": 177,
|
| 677 |
+
"startingMinuteOfDay": 750
|
| 678 |
+
},
|
| 679 |
+
{
|
| 680 |
+
"id": "100",
|
| 681 |
+
"grainIndex": 100,
|
| 682 |
+
"dayOfYear": 177,
|
| 683 |
+
"startingMinuteOfDay": 765
|
| 684 |
+
},
|
| 685 |
+
{
|
| 686 |
+
"id": "101",
|
| 687 |
+
"grainIndex": 101,
|
| 688 |
+
"dayOfYear": 177,
|
| 689 |
+
"startingMinuteOfDay": 780
|
| 690 |
+
},
|
| 691 |
+
{
|
| 692 |
+
"id": "102",
|
| 693 |
+
"grainIndex": 102,
|
| 694 |
+
"dayOfYear": 177,
|
| 695 |
+
"startingMinuteOfDay": 795
|
| 696 |
+
},
|
| 697 |
+
{
|
| 698 |
+
"id": "103",
|
| 699 |
+
"grainIndex": 103,
|
| 700 |
+
"dayOfYear": 177,
|
| 701 |
+
"startingMinuteOfDay": 810
|
| 702 |
+
},
|
| 703 |
+
{
|
| 704 |
+
"id": "104",
|
| 705 |
+
"grainIndex": 104,
|
| 706 |
+
"dayOfYear": 177,
|
| 707 |
+
"startingMinuteOfDay": 825
|
| 708 |
+
},
|
| 709 |
+
{
|
| 710 |
+
"id": "105",
|
| 711 |
+
"grainIndex": 105,
|
| 712 |
+
"dayOfYear": 177,
|
| 713 |
+
"startingMinuteOfDay": 840
|
| 714 |
+
},
|
| 715 |
+
{
|
| 716 |
+
"id": "106",
|
| 717 |
+
"grainIndex": 106,
|
| 718 |
+
"dayOfYear": 177,
|
| 719 |
+
"startingMinuteOfDay": 855
|
| 720 |
+
},
|
| 721 |
+
{
|
| 722 |
+
"id": "107",
|
| 723 |
+
"grainIndex": 107,
|
| 724 |
+
"dayOfYear": 177,
|
| 725 |
+
"startingMinuteOfDay": 870
|
| 726 |
+
},
|
| 727 |
+
{
|
| 728 |
+
"id": "108",
|
| 729 |
+
"grainIndex": 108,
|
| 730 |
+
"dayOfYear": 177,
|
| 731 |
+
"startingMinuteOfDay": 885
|
| 732 |
+
},
|
| 733 |
+
{
|
| 734 |
+
"id": "109",
|
| 735 |
+
"grainIndex": 109,
|
| 736 |
+
"dayOfYear": 177,
|
| 737 |
+
"startingMinuteOfDay": 900
|
| 738 |
+
},
|
| 739 |
+
{
|
| 740 |
+
"id": "110",
|
| 741 |
+
"grainIndex": 110,
|
| 742 |
+
"dayOfYear": 177,
|
| 743 |
+
"startingMinuteOfDay": 915
|
| 744 |
+
},
|
| 745 |
+
{
|
| 746 |
+
"id": "111",
|
| 747 |
+
"grainIndex": 111,
|
| 748 |
+
"dayOfYear": 177,
|
| 749 |
+
"startingMinuteOfDay": 930
|
| 750 |
+
},
|
| 751 |
+
{
|
| 752 |
+
"id": "112",
|
| 753 |
+
"grainIndex": 112,
|
| 754 |
+
"dayOfYear": 177,
|
| 755 |
+
"startingMinuteOfDay": 945
|
| 756 |
+
},
|
| 757 |
+
{
|
| 758 |
+
"id": "113",
|
| 759 |
+
"grainIndex": 113,
|
| 760 |
+
"dayOfYear": 177,
|
| 761 |
+
"startingMinuteOfDay": 960
|
| 762 |
+
},
|
| 763 |
+
{
|
| 764 |
+
"id": "114",
|
| 765 |
+
"grainIndex": 114,
|
| 766 |
+
"dayOfYear": 177,
|
| 767 |
+
"startingMinuteOfDay": 975
|
| 768 |
+
},
|
| 769 |
+
{
|
| 770 |
+
"id": "115",
|
| 771 |
+
"grainIndex": 115,
|
| 772 |
+
"dayOfYear": 177,
|
| 773 |
+
"startingMinuteOfDay": 990
|
| 774 |
+
},
|
| 775 |
+
{
|
| 776 |
+
"id": "116",
|
| 777 |
+
"grainIndex": 116,
|
| 778 |
+
"dayOfYear": 177,
|
| 779 |
+
"startingMinuteOfDay": 1005
|
| 780 |
+
},
|
| 781 |
+
{
|
| 782 |
+
"id": "117",
|
| 783 |
+
"grainIndex": 117,
|
| 784 |
+
"dayOfYear": 177,
|
| 785 |
+
"startingMinuteOfDay": 1020
|
| 786 |
+
},
|
| 787 |
+
{
|
| 788 |
+
"id": "118",
|
| 789 |
+
"grainIndex": 118,
|
| 790 |
+
"dayOfYear": 177,
|
| 791 |
+
"startingMinuteOfDay": 1035
|
| 792 |
+
},
|
| 793 |
+
{
|
| 794 |
+
"id": "119",
|
| 795 |
+
"grainIndex": 119,
|
| 796 |
+
"dayOfYear": 177,
|
| 797 |
+
"startingMinuteOfDay": 1050
|
| 798 |
+
},
|
| 799 |
+
{
|
| 800 |
+
"id": "120",
|
| 801 |
+
"grainIndex": 120,
|
| 802 |
+
"dayOfYear": 177,
|
| 803 |
+
"startingMinuteOfDay": 1065
|
| 804 |
+
},
|
| 805 |
+
{
|
| 806 |
+
"id": "121",
|
| 807 |
+
"grainIndex": 121,
|
| 808 |
+
"dayOfYear": 178,
|
| 809 |
+
"startingMinuteOfDay": 480
|
| 810 |
+
},
|
| 811 |
+
{
|
| 812 |
+
"id": "122",
|
| 813 |
+
"grainIndex": 122,
|
| 814 |
+
"dayOfYear": 178,
|
| 815 |
+
"startingMinuteOfDay": 495
|
| 816 |
+
},
|
| 817 |
+
{
|
| 818 |
+
"id": "123",
|
| 819 |
+
"grainIndex": 123,
|
| 820 |
+
"dayOfYear": 178,
|
| 821 |
+
"startingMinuteOfDay": 510
|
| 822 |
+
},
|
| 823 |
+
{
|
| 824 |
+
"id": "124",
|
| 825 |
+
"grainIndex": 124,
|
| 826 |
+
"dayOfYear": 178,
|
| 827 |
+
"startingMinuteOfDay": 525
|
| 828 |
+
},
|
| 829 |
+
{
|
| 830 |
+
"id": "125",
|
| 831 |
+
"grainIndex": 125,
|
| 832 |
+
"dayOfYear": 178,
|
| 833 |
+
"startingMinuteOfDay": 540
|
| 834 |
+
},
|
| 835 |
+
{
|
| 836 |
+
"id": "126",
|
| 837 |
+
"grainIndex": 126,
|
| 838 |
+
"dayOfYear": 178,
|
| 839 |
+
"startingMinuteOfDay": 555
|
| 840 |
+
},
|
| 841 |
+
{
|
| 842 |
+
"id": "127",
|
| 843 |
+
"grainIndex": 127,
|
| 844 |
+
"dayOfYear": 178,
|
| 845 |
+
"startingMinuteOfDay": 570
|
| 846 |
+
},
|
| 847 |
+
{
|
| 848 |
+
"id": "128",
|
| 849 |
+
"grainIndex": 128,
|
| 850 |
+
"dayOfYear": 178,
|
| 851 |
+
"startingMinuteOfDay": 585
|
| 852 |
+
},
|
| 853 |
+
{
|
| 854 |
+
"id": "129",
|
| 855 |
+
"grainIndex": 129,
|
| 856 |
+
"dayOfYear": 178,
|
| 857 |
+
"startingMinuteOfDay": 600
|
| 858 |
+
},
|
| 859 |
+
{
|
| 860 |
+
"id": "130",
|
| 861 |
+
"grainIndex": 130,
|
| 862 |
+
"dayOfYear": 178,
|
| 863 |
+
"startingMinuteOfDay": 615
|
| 864 |
+
},
|
| 865 |
+
{
|
| 866 |
+
"id": "131",
|
| 867 |
+
"grainIndex": 131,
|
| 868 |
+
"dayOfYear": 178,
|
| 869 |
+
"startingMinuteOfDay": 630
|
| 870 |
+
},
|
| 871 |
+
{
|
| 872 |
+
"id": "132",
|
| 873 |
+
"grainIndex": 132,
|
| 874 |
+
"dayOfYear": 178,
|
| 875 |
+
"startingMinuteOfDay": 645
|
| 876 |
+
},
|
| 877 |
+
{
|
| 878 |
+
"id": "133",
|
| 879 |
+
"grainIndex": 133,
|
| 880 |
+
"dayOfYear": 178,
|
| 881 |
+
"startingMinuteOfDay": 660
|
| 882 |
+
},
|
| 883 |
+
{
|
| 884 |
+
"id": "134",
|
| 885 |
+
"grainIndex": 134,
|
| 886 |
+
"dayOfYear": 178,
|
| 887 |
+
"startingMinuteOfDay": 675
|
| 888 |
+
},
|
| 889 |
+
{
|
| 890 |
+
"id": "135",
|
| 891 |
+
"grainIndex": 135,
|
| 892 |
+
"dayOfYear": 178,
|
| 893 |
+
"startingMinuteOfDay": 690
|
| 894 |
+
},
|
| 895 |
+
{
|
| 896 |
+
"id": "136",
|
| 897 |
+
"grainIndex": 136,
|
| 898 |
+
"dayOfYear": 178,
|
| 899 |
+
"startingMinuteOfDay": 705
|
| 900 |
+
},
|
| 901 |
+
{
|
| 902 |
+
"id": "137",
|
| 903 |
+
"grainIndex": 137,
|
| 904 |
+
"dayOfYear": 178,
|
| 905 |
+
"startingMinuteOfDay": 720
|
| 906 |
+
},
|
| 907 |
+
{
|
| 908 |
+
"id": "138",
|
| 909 |
+
"grainIndex": 138,
|
| 910 |
+
"dayOfYear": 178,
|
| 911 |
+
"startingMinuteOfDay": 735
|
| 912 |
+
},
|
| 913 |
+
{
|
| 914 |
+
"id": "139",
|
| 915 |
+
"grainIndex": 139,
|
| 916 |
+
"dayOfYear": 178,
|
| 917 |
+
"startingMinuteOfDay": 750
|
| 918 |
+
},
|
| 919 |
+
{
|
| 920 |
+
"id": "140",
|
| 921 |
+
"grainIndex": 140,
|
| 922 |
+
"dayOfYear": 178,
|
| 923 |
+
"startingMinuteOfDay": 765
|
| 924 |
+
},
|
| 925 |
+
{
|
| 926 |
+
"id": "141",
|
| 927 |
+
"grainIndex": 141,
|
| 928 |
+
"dayOfYear": 178,
|
| 929 |
+
"startingMinuteOfDay": 780
|
| 930 |
+
},
|
| 931 |
+
{
|
| 932 |
+
"id": "142",
|
| 933 |
+
"grainIndex": 142,
|
| 934 |
+
"dayOfYear": 178,
|
| 935 |
+
"startingMinuteOfDay": 795
|
| 936 |
+
},
|
| 937 |
+
{
|
| 938 |
+
"id": "143",
|
| 939 |
+
"grainIndex": 143,
|
| 940 |
+
"dayOfYear": 178,
|
| 941 |
+
"startingMinuteOfDay": 810
|
| 942 |
+
},
|
| 943 |
+
{
|
| 944 |
+
"id": "144",
|
| 945 |
+
"grainIndex": 144,
|
| 946 |
+
"dayOfYear": 178,
|
| 947 |
+
"startingMinuteOfDay": 825
|
| 948 |
+
},
|
| 949 |
+
{
|
| 950 |
+
"id": "145",
|
| 951 |
+
"grainIndex": 145,
|
| 952 |
+
"dayOfYear": 178,
|
| 953 |
+
"startingMinuteOfDay": 840
|
| 954 |
+
},
|
| 955 |
+
{
|
| 956 |
+
"id": "146",
|
| 957 |
+
"grainIndex": 146,
|
| 958 |
+
"dayOfYear": 178,
|
| 959 |
+
"startingMinuteOfDay": 855
|
| 960 |
+
},
|
| 961 |
+
{
|
| 962 |
+
"id": "147",
|
| 963 |
+
"grainIndex": 147,
|
| 964 |
+
"dayOfYear": 178,
|
| 965 |
+
"startingMinuteOfDay": 870
|
| 966 |
+
},
|
| 967 |
+
{
|
| 968 |
+
"id": "148",
|
| 969 |
+
"grainIndex": 148,
|
| 970 |
+
"dayOfYear": 178,
|
| 971 |
+
"startingMinuteOfDay": 885
|
| 972 |
+
},
|
| 973 |
+
{
|
| 974 |
+
"id": "149",
|
| 975 |
+
"grainIndex": 149,
|
| 976 |
+
"dayOfYear": 178,
|
| 977 |
+
"startingMinuteOfDay": 900
|
| 978 |
+
},
|
| 979 |
+
{
|
| 980 |
+
"id": "150",
|
| 981 |
+
"grainIndex": 150,
|
| 982 |
+
"dayOfYear": 178,
|
| 983 |
+
"startingMinuteOfDay": 915
|
| 984 |
+
},
|
| 985 |
+
{
|
| 986 |
+
"id": "151",
|
| 987 |
+
"grainIndex": 151,
|
| 988 |
+
"dayOfYear": 178,
|
| 989 |
+
"startingMinuteOfDay": 930
|
| 990 |
+
},
|
| 991 |
+
{
|
| 992 |
+
"id": "152",
|
| 993 |
+
"grainIndex": 152,
|
| 994 |
+
"dayOfYear": 178,
|
| 995 |
+
"startingMinuteOfDay": 945
|
| 996 |
+
},
|
| 997 |
+
{
|
| 998 |
+
"id": "153",
|
| 999 |
+
"grainIndex": 153,
|
| 1000 |
+
"dayOfYear": 178,
|
| 1001 |
+
"startingMinuteOfDay": 960
|
| 1002 |
+
},
|
| 1003 |
+
{
|
| 1004 |
+
"id": "154",
|
| 1005 |
+
"grainIndex": 154,
|
| 1006 |
+
"dayOfYear": 178,
|
| 1007 |
+
"startingMinuteOfDay": 975
|
| 1008 |
+
},
|
| 1009 |
+
{
|
| 1010 |
+
"id": "155",
|
| 1011 |
+
"grainIndex": 155,
|
| 1012 |
+
"dayOfYear": 178,
|
| 1013 |
+
"startingMinuteOfDay": 990
|
| 1014 |
+
},
|
| 1015 |
+
{
|
| 1016 |
+
"id": "156",
|
| 1017 |
+
"grainIndex": 156,
|
| 1018 |
+
"dayOfYear": 178,
|
| 1019 |
+
"startingMinuteOfDay": 1005
|
| 1020 |
+
},
|
| 1021 |
+
{
|
| 1022 |
+
"id": "157",
|
| 1023 |
+
"grainIndex": 157,
|
| 1024 |
+
"dayOfYear": 178,
|
| 1025 |
+
"startingMinuteOfDay": 1020
|
| 1026 |
+
},
|
| 1027 |
+
{
|
| 1028 |
+
"id": "158",
|
| 1029 |
+
"grainIndex": 158,
|
| 1030 |
+
"dayOfYear": 178,
|
| 1031 |
+
"startingMinuteOfDay": 1035
|
| 1032 |
+
},
|
| 1033 |
+
{
|
| 1034 |
+
"id": "159",
|
| 1035 |
+
"grainIndex": 159,
|
| 1036 |
+
"dayOfYear": 178,
|
| 1037 |
+
"startingMinuteOfDay": 1050
|
| 1038 |
+
},
|
| 1039 |
+
{
|
| 1040 |
+
"id": "160",
|
| 1041 |
+
"grainIndex": 160,
|
| 1042 |
+
"dayOfYear": 178,
|
| 1043 |
+
"startingMinuteOfDay": 1065
|
| 1044 |
+
}
|
| 1045 |
+
],
|
| 1046 |
+
"rooms": [
|
| 1047 |
+
{
|
| 1048 |
+
"id": "R1",
|
| 1049 |
+
"name": "Room 1",
|
| 1050 |
+
"capacity": 30
|
| 1051 |
+
},
|
| 1052 |
+
{
|
| 1053 |
+
"id": "R2",
|
| 1054 |
+
"name": "Room 2",
|
| 1055 |
+
"capacity": 20
|
| 1056 |
+
},
|
| 1057 |
+
{
|
| 1058 |
+
"id": "R3",
|
| 1059 |
+
"name": "Room 3",
|
| 1060 |
+
"capacity": 16
|
| 1061 |
+
}
|
| 1062 |
+
],
|
| 1063 |
+
"meetings": [
|
| 1064 |
+
{
|
| 1065 |
+
"id": "0",
|
| 1066 |
+
"topic": "Strategize B2B",
|
| 1067 |
+
"speakers": [],
|
| 1068 |
+
"content": "",
|
| 1069 |
+
"entireGroupMeeting": false,
|
| 1070 |
+
"durationInGrains": 8,
|
| 1071 |
+
"requiredAttendances": [
|
| 1072 |
+
{
|
| 1073 |
+
"id": "0-1",
|
| 1074 |
+
"person": "14",
|
| 1075 |
+
"meeting": "0"
|
| 1076 |
+
},
|
| 1077 |
+
{
|
| 1078 |
+
"id": "0-2",
|
| 1079 |
+
"person": "19",
|
| 1080 |
+
"meeting": "0"
|
| 1081 |
+
}
|
| 1082 |
+
],
|
| 1083 |
+
"preferredAttendances": []
|
| 1084 |
+
},
|
| 1085 |
+
{
|
| 1086 |
+
"id": "1",
|
| 1087 |
+
"topic": "Fast track e-business",
|
| 1088 |
+
"speakers": [],
|
| 1089 |
+
"content": "",
|
| 1090 |
+
"entireGroupMeeting": false,
|
| 1091 |
+
"durationInGrains": 16,
|
| 1092 |
+
"requiredAttendances": [
|
| 1093 |
+
{
|
| 1094 |
+
"id": "1-1",
|
| 1095 |
+
"person": "15",
|
| 1096 |
+
"meeting": "1"
|
| 1097 |
+
},
|
| 1098 |
+
{
|
| 1099 |
+
"id": "1-2",
|
| 1100 |
+
"person": "17",
|
| 1101 |
+
"meeting": "1"
|
| 1102 |
+
},
|
| 1103 |
+
{
|
| 1104 |
+
"id": "1-3",
|
| 1105 |
+
"person": "16",
|
| 1106 |
+
"meeting": "1"
|
| 1107 |
+
},
|
| 1108 |
+
{
|
| 1109 |
+
"id": "1-4",
|
| 1110 |
+
"person": "6",
|
| 1111 |
+
"meeting": "1"
|
| 1112 |
+
},
|
| 1113 |
+
{
|
| 1114 |
+
"id": "1-5",
|
| 1115 |
+
"person": "4",
|
| 1116 |
+
"meeting": "1"
|
| 1117 |
+
},
|
| 1118 |
+
{
|
| 1119 |
+
"id": "1-6",
|
| 1120 |
+
"person": "11",
|
| 1121 |
+
"meeting": "1"
|
| 1122 |
+
},
|
| 1123 |
+
{
|
| 1124 |
+
"id": "1-7",
|
| 1125 |
+
"person": "10",
|
| 1126 |
+
"meeting": "1"
|
| 1127 |
+
}
|
| 1128 |
+
],
|
| 1129 |
+
"preferredAttendances": [
|
| 1130 |
+
{
|
| 1131 |
+
"id": "1-8",
|
| 1132 |
+
"person": "3",
|
| 1133 |
+
"meeting": "1"
|
| 1134 |
+
},
|
| 1135 |
+
{
|
| 1136 |
+
"id": "1-9",
|
| 1137 |
+
"person": "8",
|
| 1138 |
+
"meeting": "1"
|
| 1139 |
+
},
|
| 1140 |
+
{
|
| 1141 |
+
"id": "1-10",
|
| 1142 |
+
"person": "1",
|
| 1143 |
+
"meeting": "1"
|
| 1144 |
+
}
|
| 1145 |
+
]
|
| 1146 |
+
},
|
| 1147 |
+
{
|
| 1148 |
+
"id": "2",
|
| 1149 |
+
"topic": "Cross sell virtualization",
|
| 1150 |
+
"speakers": [],
|
| 1151 |
+
"content": "",
|
| 1152 |
+
"entireGroupMeeting": false,
|
| 1153 |
+
"durationInGrains": 8,
|
| 1154 |
+
"requiredAttendances": [
|
| 1155 |
+
{
|
| 1156 |
+
"id": "2-1",
|
| 1157 |
+
"person": "3",
|
| 1158 |
+
"meeting": "2"
|
| 1159 |
+
},
|
| 1160 |
+
{
|
| 1161 |
+
"id": "2-2",
|
| 1162 |
+
"person": "2",
|
| 1163 |
+
"meeting": "2"
|
| 1164 |
+
},
|
| 1165 |
+
{
|
| 1166 |
+
"id": "2-3",
|
| 1167 |
+
"person": "15",
|
| 1168 |
+
"meeting": "2"
|
| 1169 |
+
},
|
| 1170 |
+
{
|
| 1171 |
+
"id": "2-4",
|
| 1172 |
+
"person": "8",
|
| 1173 |
+
"meeting": "2"
|
| 1174 |
+
},
|
| 1175 |
+
{
|
| 1176 |
+
"id": "2-5",
|
| 1177 |
+
"person": "1",
|
| 1178 |
+
"meeting": "2"
|
| 1179 |
+
}
|
| 1180 |
+
],
|
| 1181 |
+
"preferredAttendances": []
|
| 1182 |
+
},
|
| 1183 |
+
{
|
| 1184 |
+
"id": "3",
|
| 1185 |
+
"topic": "Profitize multitasking",
|
| 1186 |
+
"speakers": [],
|
| 1187 |
+
"content": "",
|
| 1188 |
+
"entireGroupMeeting": false,
|
| 1189 |
+
"durationInGrains": 16,
|
| 1190 |
+
"requiredAttendances": [
|
| 1191 |
+
{
|
| 1192 |
+
"id": "3-1",
|
| 1193 |
+
"person": "0",
|
| 1194 |
+
"meeting": "3"
|
| 1195 |
+
},
|
| 1196 |
+
{
|
| 1197 |
+
"id": "3-2",
|
| 1198 |
+
"person": "18",
|
| 1199 |
+
"meeting": "3"
|
| 1200 |
+
}
|
| 1201 |
+
],
|
| 1202 |
+
"preferredAttendances": [
|
| 1203 |
+
{
|
| 1204 |
+
"id": "3-3",
|
| 1205 |
+
"person": "12",
|
| 1206 |
+
"meeting": "3"
|
| 1207 |
+
},
|
| 1208 |
+
{
|
| 1209 |
+
"id": "3-4",
|
| 1210 |
+
"person": "19",
|
| 1211 |
+
"meeting": "3"
|
| 1212 |
+
}
|
| 1213 |
+
]
|
| 1214 |
+
},
|
| 1215 |
+
{
|
| 1216 |
+
"id": "4",
|
| 1217 |
+
"topic": "Transform one stop shop",
|
| 1218 |
+
"speakers": [],
|
| 1219 |
+
"content": "",
|
| 1220 |
+
"entireGroupMeeting": false,
|
| 1221 |
+
"durationInGrains": 12,
|
| 1222 |
+
"requiredAttendances": [
|
| 1223 |
+
{
|
| 1224 |
+
"id": "4-1",
|
| 1225 |
+
"person": "19",
|
| 1226 |
+
"meeting": "4"
|
| 1227 |
+
},
|
| 1228 |
+
{
|
| 1229 |
+
"id": "4-2",
|
| 1230 |
+
"person": "18",
|
| 1231 |
+
"meeting": "4"
|
| 1232 |
+
},
|
| 1233 |
+
{
|
| 1234 |
+
"id": "4-3",
|
| 1235 |
+
"person": "11",
|
| 1236 |
+
"meeting": "4"
|
| 1237 |
+
},
|
| 1238 |
+
{
|
| 1239 |
+
"id": "4-4",
|
| 1240 |
+
"person": "0",
|
| 1241 |
+
"meeting": "4"
|
| 1242 |
+
},
|
| 1243 |
+
{
|
| 1244 |
+
"id": "4-5",
|
| 1245 |
+
"person": "9",
|
| 1246 |
+
"meeting": "4"
|
| 1247 |
+
},
|
| 1248 |
+
{
|
| 1249 |
+
"id": "4-6",
|
| 1250 |
+
"person": "17",
|
| 1251 |
+
"meeting": "4"
|
| 1252 |
+
}
|
| 1253 |
+
],
|
| 1254 |
+
"preferredAttendances": []
|
| 1255 |
+
},
|
| 1256 |
+
{
|
| 1257 |
+
"id": "5",
|
| 1258 |
+
"topic": "Engage braindumps",
|
| 1259 |
+
"speakers": [],
|
| 1260 |
+
"content": "",
|
| 1261 |
+
"entireGroupMeeting": false,
|
| 1262 |
+
"durationInGrains": 12,
|
| 1263 |
+
"requiredAttendances": [],
|
| 1264 |
+
"preferredAttendances": []
|
| 1265 |
+
},
|
| 1266 |
+
{
|
| 1267 |
+
"id": "6",
|
| 1268 |
+
"topic": "Downsize data mining",
|
| 1269 |
+
"speakers": [],
|
| 1270 |
+
"content": "",
|
| 1271 |
+
"entireGroupMeeting": false,
|
| 1272 |
+
"durationInGrains": 8,
|
| 1273 |
+
"requiredAttendances": [
|
| 1274 |
+
{
|
| 1275 |
+
"id": "6-1",
|
| 1276 |
+
"person": "15",
|
| 1277 |
+
"meeting": "6"
|
| 1278 |
+
},
|
| 1279 |
+
{
|
| 1280 |
+
"id": "6-2",
|
| 1281 |
+
"person": "4",
|
| 1282 |
+
"meeting": "6"
|
| 1283 |
+
}
|
| 1284 |
+
],
|
| 1285 |
+
"preferredAttendances": [
|
| 1286 |
+
{
|
| 1287 |
+
"id": "6-3",
|
| 1288 |
+
"person": "6",
|
| 1289 |
+
"meeting": "6"
|
| 1290 |
+
},
|
| 1291 |
+
{
|
| 1292 |
+
"id": "6-4",
|
| 1293 |
+
"person": "14",
|
| 1294 |
+
"meeting": "6"
|
| 1295 |
+
},
|
| 1296 |
+
{
|
| 1297 |
+
"id": "6-5",
|
| 1298 |
+
"person": "16",
|
| 1299 |
+
"meeting": "6"
|
| 1300 |
+
}
|
| 1301 |
+
]
|
| 1302 |
+
},
|
| 1303 |
+
{
|
| 1304 |
+
"id": "7",
|
| 1305 |
+
"topic": "Ramp up policies",
|
| 1306 |
+
"speakers": [],
|
| 1307 |
+
"content": "",
|
| 1308 |
+
"entireGroupMeeting": false,
|
| 1309 |
+
"durationInGrains": 16,
|
| 1310 |
+
"requiredAttendances": [
|
| 1311 |
+
{
|
| 1312 |
+
"id": "7-1",
|
| 1313 |
+
"person": "0",
|
| 1314 |
+
"meeting": "7"
|
| 1315 |
+
},
|
| 1316 |
+
{
|
| 1317 |
+
"id": "7-2",
|
| 1318 |
+
"person": "14",
|
| 1319 |
+
"meeting": "7"
|
| 1320 |
+
},
|
| 1321 |
+
{
|
| 1322 |
+
"id": "7-3",
|
| 1323 |
+
"person": "7",
|
| 1324 |
+
"meeting": "7"
|
| 1325 |
+
},
|
| 1326 |
+
{
|
| 1327 |
+
"id": "7-4",
|
| 1328 |
+
"person": "3",
|
| 1329 |
+
"meeting": "7"
|
| 1330 |
+
},
|
| 1331 |
+
{
|
| 1332 |
+
"id": "7-5",
|
| 1333 |
+
"person": "6",
|
| 1334 |
+
"meeting": "7"
|
| 1335 |
+
},
|
| 1336 |
+
{
|
| 1337 |
+
"id": "7-6",
|
| 1338 |
+
"person": "12",
|
| 1339 |
+
"meeting": "7"
|
| 1340 |
+
},
|
| 1341 |
+
{
|
| 1342 |
+
"id": "7-7",
|
| 1343 |
+
"person": "4",
|
| 1344 |
+
"meeting": "7"
|
| 1345 |
+
},
|
| 1346 |
+
{
|
| 1347 |
+
"id": "7-8",
|
| 1348 |
+
"person": "18",
|
| 1349 |
+
"meeting": "7"
|
| 1350 |
+
}
|
| 1351 |
+
],
|
| 1352 |
+
"preferredAttendances": []
|
| 1353 |
+
},
|
| 1354 |
+
{
|
| 1355 |
+
"id": "8",
|
| 1356 |
+
"topic": "On board synergies",
|
| 1357 |
+
"speakers": [],
|
| 1358 |
+
"content": "",
|
| 1359 |
+
"entireGroupMeeting": false,
|
| 1360 |
+
"durationInGrains": 12,
|
| 1361 |
+
"requiredAttendances": [],
|
| 1362 |
+
"preferredAttendances": [
|
| 1363 |
+
{
|
| 1364 |
+
"id": "8-1",
|
| 1365 |
+
"person": "1",
|
| 1366 |
+
"meeting": "8"
|
| 1367 |
+
},
|
| 1368 |
+
{
|
| 1369 |
+
"id": "8-2",
|
| 1370 |
+
"person": "2",
|
| 1371 |
+
"meeting": "8"
|
| 1372 |
+
},
|
| 1373 |
+
{
|
| 1374 |
+
"id": "8-3",
|
| 1375 |
+
"person": "6",
|
| 1376 |
+
"meeting": "8"
|
| 1377 |
+
},
|
| 1378 |
+
{
|
| 1379 |
+
"id": "8-4",
|
| 1380 |
+
"person": "18",
|
| 1381 |
+
"meeting": "8"
|
| 1382 |
+
}
|
| 1383 |
+
]
|
| 1384 |
+
},
|
| 1385 |
+
{
|
| 1386 |
+
"id": "9",
|
| 1387 |
+
"topic": "Reinvigorate user experience",
|
| 1388 |
+
"speakers": [],
|
| 1389 |
+
"content": "",
|
| 1390 |
+
"entireGroupMeeting": false,
|
| 1391 |
+
"durationInGrains": 16,
|
| 1392 |
+
"requiredAttendances": [],
|
| 1393 |
+
"preferredAttendances": [
|
| 1394 |
+
{
|
| 1395 |
+
"id": "9-1",
|
| 1396 |
+
"person": "14",
|
| 1397 |
+
"meeting": "9"
|
| 1398 |
+
},
|
| 1399 |
+
{
|
| 1400 |
+
"id": "9-2",
|
| 1401 |
+
"person": "9",
|
| 1402 |
+
"meeting": "9"
|
| 1403 |
+
},
|
| 1404 |
+
{
|
| 1405 |
+
"id": "9-3",
|
| 1406 |
+
"person": "18",
|
| 1407 |
+
"meeting": "9"
|
| 1408 |
+
}
|
| 1409 |
+
]
|
| 1410 |
+
},
|
| 1411 |
+
{
|
| 1412 |
+
"id": "10",
|
| 1413 |
+
"topic": "Strategize e-business",
|
| 1414 |
+
"speakers": [],
|
| 1415 |
+
"content": "",
|
| 1416 |
+
"entireGroupMeeting": false,
|
| 1417 |
+
"durationInGrains": 16,
|
| 1418 |
+
"requiredAttendances": [],
|
| 1419 |
+
"preferredAttendances": [
|
| 1420 |
+
{
|
| 1421 |
+
"id": "10-1",
|
| 1422 |
+
"person": "17",
|
| 1423 |
+
"meeting": "10"
|
| 1424 |
+
},
|
| 1425 |
+
{
|
| 1426 |
+
"id": "10-2",
|
| 1427 |
+
"person": "13",
|
| 1428 |
+
"meeting": "10"
|
| 1429 |
+
}
|
| 1430 |
+
]
|
| 1431 |
+
},
|
| 1432 |
+
{
|
| 1433 |
+
"id": "11",
|
| 1434 |
+
"topic": "Fast track virtualization",
|
| 1435 |
+
"speakers": [],
|
| 1436 |
+
"content": "",
|
| 1437 |
+
"entireGroupMeeting": false,
|
| 1438 |
+
"durationInGrains": 12,
|
| 1439 |
+
"requiredAttendances": [],
|
| 1440 |
+
"preferredAttendances": [
|
| 1441 |
+
{
|
| 1442 |
+
"id": "11-1",
|
| 1443 |
+
"person": "15",
|
| 1444 |
+
"meeting": "11"
|
| 1445 |
+
},
|
| 1446 |
+
{
|
| 1447 |
+
"id": "11-2",
|
| 1448 |
+
"person": "6",
|
| 1449 |
+
"meeting": "11"
|
| 1450 |
+
},
|
| 1451 |
+
{
|
| 1452 |
+
"id": "11-3",
|
| 1453 |
+
"person": "2",
|
| 1454 |
+
"meeting": "11"
|
| 1455 |
+
},
|
| 1456 |
+
{
|
| 1457 |
+
"id": "11-4",
|
| 1458 |
+
"person": "9",
|
| 1459 |
+
"meeting": "11"
|
| 1460 |
+
},
|
| 1461 |
+
{
|
| 1462 |
+
"id": "11-5",
|
| 1463 |
+
"person": "7",
|
| 1464 |
+
"meeting": "11"
|
| 1465 |
+
},
|
| 1466 |
+
{
|
| 1467 |
+
"id": "11-6",
|
| 1468 |
+
"person": "8",
|
| 1469 |
+
"meeting": "11"
|
| 1470 |
+
},
|
| 1471 |
+
{
|
| 1472 |
+
"id": "11-7",
|
| 1473 |
+
"person": "11",
|
| 1474 |
+
"meeting": "11"
|
| 1475 |
+
},
|
| 1476 |
+
{
|
| 1477 |
+
"id": "11-8",
|
| 1478 |
+
"person": "0",
|
| 1479 |
+
"meeting": "11"
|
| 1480 |
+
},
|
| 1481 |
+
{
|
| 1482 |
+
"id": "11-9",
|
| 1483 |
+
"person": "13",
|
| 1484 |
+
"meeting": "11"
|
| 1485 |
+
}
|
| 1486 |
+
]
|
| 1487 |
+
},
|
| 1488 |
+
{
|
| 1489 |
+
"id": "12",
|
| 1490 |
+
"topic": "Cross sell multitasking",
|
| 1491 |
+
"speakers": [],
|
| 1492 |
+
"content": "",
|
| 1493 |
+
"entireGroupMeeting": false,
|
| 1494 |
+
"durationInGrains": 16,
|
| 1495 |
+
"requiredAttendances": [
|
| 1496 |
+
{
|
| 1497 |
+
"id": "12-1",
|
| 1498 |
+
"person": "12",
|
| 1499 |
+
"meeting": "12"
|
| 1500 |
+
},
|
| 1501 |
+
{
|
| 1502 |
+
"id": "12-2",
|
| 1503 |
+
"person": "11",
|
| 1504 |
+
"meeting": "12"
|
| 1505 |
+
}
|
| 1506 |
+
],
|
| 1507 |
+
"preferredAttendances": [
|
| 1508 |
+
{
|
| 1509 |
+
"id": "12-3",
|
| 1510 |
+
"person": "9",
|
| 1511 |
+
"meeting": "12"
|
| 1512 |
+
},
|
| 1513 |
+
{
|
| 1514 |
+
"id": "12-4",
|
| 1515 |
+
"person": "2",
|
| 1516 |
+
"meeting": "12"
|
| 1517 |
+
}
|
| 1518 |
+
]
|
| 1519 |
+
},
|
| 1520 |
+
{
|
| 1521 |
+
"id": "13",
|
| 1522 |
+
"topic": "Profitize one stop shop",
|
| 1523 |
+
"speakers": [],
|
| 1524 |
+
"content": "",
|
| 1525 |
+
"entireGroupMeeting": false,
|
| 1526 |
+
"durationInGrains": 12,
|
| 1527 |
+
"requiredAttendances": [],
|
| 1528 |
+
"preferredAttendances": [
|
| 1529 |
+
{
|
| 1530 |
+
"id": "13-1",
|
| 1531 |
+
"person": "16",
|
| 1532 |
+
"meeting": "13"
|
| 1533 |
+
},
|
| 1534 |
+
{
|
| 1535 |
+
"id": "13-2",
|
| 1536 |
+
"person": "0",
|
| 1537 |
+
"meeting": "13"
|
| 1538 |
+
}
|
| 1539 |
+
]
|
| 1540 |
+
},
|
| 1541 |
+
{
|
| 1542 |
+
"id": "14",
|
| 1543 |
+
"topic": "Transform braindumps",
|
| 1544 |
+
"speakers": [],
|
| 1545 |
+
"content": "",
|
| 1546 |
+
"entireGroupMeeting": false,
|
| 1547 |
+
"durationInGrains": 16,
|
| 1548 |
+
"requiredAttendances": [
|
| 1549 |
+
{
|
| 1550 |
+
"id": "14-1",
|
| 1551 |
+
"person": "4",
|
| 1552 |
+
"meeting": "14"
|
| 1553 |
+
},
|
| 1554 |
+
{
|
| 1555 |
+
"id": "14-2",
|
| 1556 |
+
"person": "17",
|
| 1557 |
+
"meeting": "14"
|
| 1558 |
+
}
|
| 1559 |
+
],
|
| 1560 |
+
"preferredAttendances": []
|
| 1561 |
+
},
|
| 1562 |
+
{
|
| 1563 |
+
"id": "15",
|
| 1564 |
+
"topic": "Engage data mining",
|
| 1565 |
+
"speakers": [],
|
| 1566 |
+
"content": "",
|
| 1567 |
+
"entireGroupMeeting": false,
|
| 1568 |
+
"durationInGrains": 8,
|
| 1569 |
+
"requiredAttendances": [
|
| 1570 |
+
{
|
| 1571 |
+
"id": "15-1",
|
| 1572 |
+
"person": "6",
|
| 1573 |
+
"meeting": "15"
|
| 1574 |
+
},
|
| 1575 |
+
{
|
| 1576 |
+
"id": "15-2",
|
| 1577 |
+
"person": "18",
|
| 1578 |
+
"meeting": "15"
|
| 1579 |
+
}
|
| 1580 |
+
],
|
| 1581 |
+
"preferredAttendances": [
|
| 1582 |
+
{
|
| 1583 |
+
"id": "15-3",
|
| 1584 |
+
"person": "7",
|
| 1585 |
+
"meeting": "15"
|
| 1586 |
+
}
|
| 1587 |
+
]
|
| 1588 |
+
},
|
| 1589 |
+
{
|
| 1590 |
+
"id": "16",
|
| 1591 |
+
"topic": "Downsize policies",
|
| 1592 |
+
"speakers": [],
|
| 1593 |
+
"content": "",
|
| 1594 |
+
"entireGroupMeeting": false,
|
| 1595 |
+
"durationInGrains": 8,
|
| 1596 |
+
"requiredAttendances": [],
|
| 1597 |
+
"preferredAttendances": []
|
| 1598 |
+
},
|
| 1599 |
+
{
|
| 1600 |
+
"id": "17",
|
| 1601 |
+
"topic": "Ramp up synergies",
|
| 1602 |
+
"speakers": [],
|
| 1603 |
+
"content": "",
|
| 1604 |
+
"entireGroupMeeting": false,
|
| 1605 |
+
"durationInGrains": 8,
|
| 1606 |
+
"requiredAttendances": [
|
| 1607 |
+
{
|
| 1608 |
+
"id": "17-1",
|
| 1609 |
+
"person": "6",
|
| 1610 |
+
"meeting": "17"
|
| 1611 |
+
},
|
| 1612 |
+
{
|
| 1613 |
+
"id": "17-2",
|
| 1614 |
+
"person": "11",
|
| 1615 |
+
"meeting": "17"
|
| 1616 |
+
}
|
| 1617 |
+
],
|
| 1618 |
+
"preferredAttendances": []
|
| 1619 |
+
},
|
| 1620 |
+
{
|
| 1621 |
+
"id": "18",
|
| 1622 |
+
"topic": "On board user experience",
|
| 1623 |
+
"speakers": [],
|
| 1624 |
+
"content": "",
|
| 1625 |
+
"entireGroupMeeting": false,
|
| 1626 |
+
"durationInGrains": 12,
|
| 1627 |
+
"requiredAttendances": [
|
| 1628 |
+
{
|
| 1629 |
+
"id": "18-1",
|
| 1630 |
+
"person": "11",
|
| 1631 |
+
"meeting": "18"
|
| 1632 |
+
},
|
| 1633 |
+
{
|
| 1634 |
+
"id": "18-2",
|
| 1635 |
+
"person": "6",
|
| 1636 |
+
"meeting": "18"
|
| 1637 |
+
}
|
| 1638 |
+
],
|
| 1639 |
+
"preferredAttendances": []
|
| 1640 |
+
},
|
| 1641 |
+
{
|
| 1642 |
+
"id": "19",
|
| 1643 |
+
"topic": "Reinvigorate B2B",
|
| 1644 |
+
"speakers": [],
|
| 1645 |
+
"content": "",
|
| 1646 |
+
"entireGroupMeeting": false,
|
| 1647 |
+
"durationInGrains": 8,
|
| 1648 |
+
"requiredAttendances": [],
|
| 1649 |
+
"preferredAttendances": [
|
| 1650 |
+
{
|
| 1651 |
+
"id": "19-1",
|
| 1652 |
+
"person": "12",
|
| 1653 |
+
"meeting": "19"
|
| 1654 |
+
},
|
| 1655 |
+
{
|
| 1656 |
+
"id": "19-2",
|
| 1657 |
+
"person": "16",
|
| 1658 |
+
"meeting": "19"
|
| 1659 |
+
},
|
| 1660 |
+
{
|
| 1661 |
+
"id": "19-3",
|
| 1662 |
+
"person": "18",
|
| 1663 |
+
"meeting": "19"
|
| 1664 |
+
}
|
| 1665 |
+
]
|
| 1666 |
+
},
|
| 1667 |
+
{
|
| 1668 |
+
"id": "20",
|
| 1669 |
+
"topic": "Strategize virtualization",
|
| 1670 |
+
"speakers": [],
|
| 1671 |
+
"content": "",
|
| 1672 |
+
"entireGroupMeeting": false,
|
| 1673 |
+
"durationInGrains": 8,
|
| 1674 |
+
"requiredAttendances": [],
|
| 1675 |
+
"preferredAttendances": []
|
| 1676 |
+
},
|
| 1677 |
+
{
|
| 1678 |
+
"id": "21",
|
| 1679 |
+
"topic": "Fast track multitasking",
|
| 1680 |
+
"speakers": [],
|
| 1681 |
+
"content": "",
|
| 1682 |
+
"entireGroupMeeting": false,
|
| 1683 |
+
"durationInGrains": 12,
|
| 1684 |
+
"requiredAttendances": [
|
| 1685 |
+
{
|
| 1686 |
+
"id": "21-1",
|
| 1687 |
+
"person": "5",
|
| 1688 |
+
"meeting": "21"
|
| 1689 |
+
},
|
| 1690 |
+
{
|
| 1691 |
+
"id": "21-2",
|
| 1692 |
+
"person": "4",
|
| 1693 |
+
"meeting": "21"
|
| 1694 |
+
},
|
| 1695 |
+
{
|
| 1696 |
+
"id": "21-3",
|
| 1697 |
+
"person": "17",
|
| 1698 |
+
"meeting": "21"
|
| 1699 |
+
}
|
| 1700 |
+
],
|
| 1701 |
+
"preferredAttendances": []
|
| 1702 |
+
},
|
| 1703 |
+
{
|
| 1704 |
+
"id": "22",
|
| 1705 |
+
"topic": "Cross sell one stop shop",
|
| 1706 |
+
"speakers": [],
|
| 1707 |
+
"content": "",
|
| 1708 |
+
"entireGroupMeeting": false,
|
| 1709 |
+
"durationInGrains": 8,
|
| 1710 |
+
"requiredAttendances": [
|
| 1711 |
+
{
|
| 1712 |
+
"id": "22-1",
|
| 1713 |
+
"person": "3",
|
| 1714 |
+
"meeting": "22"
|
| 1715 |
+
},
|
| 1716 |
+
{
|
| 1717 |
+
"id": "22-2",
|
| 1718 |
+
"person": "8",
|
| 1719 |
+
"meeting": "22"
|
| 1720 |
+
},
|
| 1721 |
+
{
|
| 1722 |
+
"id": "22-3",
|
| 1723 |
+
"person": "1",
|
| 1724 |
+
"meeting": "22"
|
| 1725 |
+
},
|
| 1726 |
+
{
|
| 1727 |
+
"id": "22-4",
|
| 1728 |
+
"person": "0",
|
| 1729 |
+
"meeting": "22"
|
| 1730 |
+
},
|
| 1731 |
+
{
|
| 1732 |
+
"id": "22-5",
|
| 1733 |
+
"person": "6",
|
| 1734 |
+
"meeting": "22"
|
| 1735 |
+
},
|
| 1736 |
+
{
|
| 1737 |
+
"id": "22-6",
|
| 1738 |
+
"person": "14",
|
| 1739 |
+
"meeting": "22"
|
| 1740 |
+
},
|
| 1741 |
+
{
|
| 1742 |
+
"id": "22-7",
|
| 1743 |
+
"person": "13",
|
| 1744 |
+
"meeting": "22"
|
| 1745 |
+
},
|
| 1746 |
+
{
|
| 1747 |
+
"id": "22-8",
|
| 1748 |
+
"person": "2",
|
| 1749 |
+
"meeting": "22"
|
| 1750 |
+
},
|
| 1751 |
+
{
|
| 1752 |
+
"id": "22-9",
|
| 1753 |
+
"person": "16",
|
| 1754 |
+
"meeting": "22"
|
| 1755 |
+
},
|
| 1756 |
+
{
|
| 1757 |
+
"id": "22-10",
|
| 1758 |
+
"person": "18",
|
| 1759 |
+
"meeting": "22"
|
| 1760 |
+
}
|
| 1761 |
+
],
|
| 1762 |
+
"preferredAttendances": []
|
| 1763 |
+
},
|
| 1764 |
+
{
|
| 1765 |
+
"id": "23",
|
| 1766 |
+
"topic": "Reinvigorate multitasking",
|
| 1767 |
+
"speakers": [],
|
| 1768 |
+
"content": "",
|
| 1769 |
+
"entireGroupMeeting": false,
|
| 1770 |
+
"durationInGrains": 8,
|
| 1771 |
+
"requiredAttendances": [
|
| 1772 |
+
{
|
| 1773 |
+
"id": "23-1",
|
| 1774 |
+
"person": "16",
|
| 1775 |
+
"meeting": "23"
|
| 1776 |
+
},
|
| 1777 |
+
{
|
| 1778 |
+
"id": "23-2",
|
| 1779 |
+
"person": "12",
|
| 1780 |
+
"meeting": "23"
|
| 1781 |
+
},
|
| 1782 |
+
{
|
| 1783 |
+
"id": "23-3",
|
| 1784 |
+
"person": "3",
|
| 1785 |
+
"meeting": "23"
|
| 1786 |
+
},
|
| 1787 |
+
{
|
| 1788 |
+
"id": "23-4",
|
| 1789 |
+
"person": "13",
|
| 1790 |
+
"meeting": "23"
|
| 1791 |
+
},
|
| 1792 |
+
{
|
| 1793 |
+
"id": "23-5",
|
| 1794 |
+
"person": "18",
|
| 1795 |
+
"meeting": "23"
|
| 1796 |
+
},
|
| 1797 |
+
{
|
| 1798 |
+
"id": "23-6",
|
| 1799 |
+
"person": "10",
|
| 1800 |
+
"meeting": "23"
|
| 1801 |
+
}
|
| 1802 |
+
],
|
| 1803 |
+
"preferredAttendances": []
|
| 1804 |
+
}
|
| 1805 |
+
],
|
| 1806 |
+
"meetingAssignments": [
|
| 1807 |
+
{
|
| 1808 |
+
"id": "0",
|
| 1809 |
+
"meeting": "0",
|
| 1810 |
+
"pinned": false,
|
| 1811 |
+
"startingTimeGrain": null,
|
| 1812 |
+
"room": null
|
| 1813 |
+
},
|
| 1814 |
+
{
|
| 1815 |
+
"id": "1",
|
| 1816 |
+
"meeting": "1",
|
| 1817 |
+
"pinned": false,
|
| 1818 |
+
"startingTimeGrain": null,
|
| 1819 |
+
"room": null
|
| 1820 |
+
},
|
| 1821 |
+
{
|
| 1822 |
+
"id": "2",
|
| 1823 |
+
"meeting": "2",
|
| 1824 |
+
"pinned": false,
|
| 1825 |
+
"startingTimeGrain": null,
|
| 1826 |
+
"room": null
|
| 1827 |
+
},
|
| 1828 |
+
{
|
| 1829 |
+
"id": "3",
|
| 1830 |
+
"meeting": "3",
|
| 1831 |
+
"pinned": false,
|
| 1832 |
+
"startingTimeGrain": null,
|
| 1833 |
+
"room": null
|
| 1834 |
+
},
|
| 1835 |
+
{
|
| 1836 |
+
"id": "4",
|
| 1837 |
+
"meeting": "4",
|
| 1838 |
+
"pinned": false,
|
| 1839 |
+
"startingTimeGrain": null,
|
| 1840 |
+
"room": null
|
| 1841 |
+
},
|
| 1842 |
+
{
|
| 1843 |
+
"id": "5",
|
| 1844 |
+
"meeting": "5",
|
| 1845 |
+
"pinned": false,
|
| 1846 |
+
"startingTimeGrain": null,
|
| 1847 |
+
"room": null
|
| 1848 |
+
},
|
| 1849 |
+
{
|
| 1850 |
+
"id": "6",
|
| 1851 |
+
"meeting": "6",
|
| 1852 |
+
"pinned": false,
|
| 1853 |
+
"startingTimeGrain": null,
|
| 1854 |
+
"room": null
|
| 1855 |
+
},
|
| 1856 |
+
{
|
| 1857 |
+
"id": "7",
|
| 1858 |
+
"meeting": "7",
|
| 1859 |
+
"pinned": false,
|
| 1860 |
+
"startingTimeGrain": null,
|
| 1861 |
+
"room": null
|
| 1862 |
+
},
|
| 1863 |
+
{
|
| 1864 |
+
"id": "8",
|
| 1865 |
+
"meeting": "8",
|
| 1866 |
+
"pinned": false,
|
| 1867 |
+
"startingTimeGrain": null,
|
| 1868 |
+
"room": null
|
| 1869 |
+
},
|
| 1870 |
+
{
|
| 1871 |
+
"id": "9",
|
| 1872 |
+
"meeting": "9",
|
| 1873 |
+
"pinned": false,
|
| 1874 |
+
"startingTimeGrain": null,
|
| 1875 |
+
"room": null
|
| 1876 |
+
},
|
| 1877 |
+
{
|
| 1878 |
+
"id": "10",
|
| 1879 |
+
"meeting": "10",
|
| 1880 |
+
"pinned": false,
|
| 1881 |
+
"startingTimeGrain": null,
|
| 1882 |
+
"room": null
|
| 1883 |
+
},
|
| 1884 |
+
{
|
| 1885 |
+
"id": "11",
|
| 1886 |
+
"meeting": "11",
|
| 1887 |
+
"pinned": false,
|
| 1888 |
+
"startingTimeGrain": null,
|
| 1889 |
+
"room": null
|
| 1890 |
+
},
|
| 1891 |
+
{
|
| 1892 |
+
"id": "12",
|
| 1893 |
+
"meeting": "12",
|
| 1894 |
+
"pinned": false,
|
| 1895 |
+
"startingTimeGrain": null,
|
| 1896 |
+
"room": null
|
| 1897 |
+
},
|
| 1898 |
+
{
|
| 1899 |
+
"id": "13",
|
| 1900 |
+
"meeting": "13",
|
| 1901 |
+
"pinned": false,
|
| 1902 |
+
"startingTimeGrain": null,
|
| 1903 |
+
"room": null
|
| 1904 |
+
},
|
| 1905 |
+
{
|
| 1906 |
+
"id": "14",
|
| 1907 |
+
"meeting": "14",
|
| 1908 |
+
"pinned": false,
|
| 1909 |
+
"startingTimeGrain": null,
|
| 1910 |
+
"room": null
|
| 1911 |
+
},
|
| 1912 |
+
{
|
| 1913 |
+
"id": "15",
|
| 1914 |
+
"meeting": "15",
|
| 1915 |
+
"pinned": false,
|
| 1916 |
+
"startingTimeGrain": null,
|
| 1917 |
+
"room": null
|
| 1918 |
+
},
|
| 1919 |
+
{
|
| 1920 |
+
"id": "16",
|
| 1921 |
+
"meeting": "16",
|
| 1922 |
+
"pinned": false,
|
| 1923 |
+
"startingTimeGrain": null,
|
| 1924 |
+
"room": null
|
| 1925 |
+
},
|
| 1926 |
+
{
|
| 1927 |
+
"id": "17",
|
| 1928 |
+
"meeting": "17",
|
| 1929 |
+
"pinned": false,
|
| 1930 |
+
"startingTimeGrain": null,
|
| 1931 |
+
"room": null
|
| 1932 |
+
},
|
| 1933 |
+
{
|
| 1934 |
+
"id": "18",
|
| 1935 |
+
"meeting": "18",
|
| 1936 |
+
"pinned": false,
|
| 1937 |
+
"startingTimeGrain": null,
|
| 1938 |
+
"room": null
|
| 1939 |
+
},
|
| 1940 |
+
{
|
| 1941 |
+
"id": "19",
|
| 1942 |
+
"meeting": "19",
|
| 1943 |
+
"pinned": false,
|
| 1944 |
+
"startingTimeGrain": null,
|
| 1945 |
+
"room": null
|
| 1946 |
+
},
|
| 1947 |
+
{
|
| 1948 |
+
"id": "20",
|
| 1949 |
+
"meeting": "20",
|
| 1950 |
+
"pinned": false,
|
| 1951 |
+
"startingTimeGrain": null,
|
| 1952 |
+
"room": null
|
| 1953 |
+
},
|
| 1954 |
+
{
|
| 1955 |
+
"id": "21",
|
| 1956 |
+
"meeting": "21",
|
| 1957 |
+
"pinned": false,
|
| 1958 |
+
"startingTimeGrain": null,
|
| 1959 |
+
"room": null
|
| 1960 |
+
},
|
| 1961 |
+
{
|
| 1962 |
+
"id": "22",
|
| 1963 |
+
"meeting": "22",
|
| 1964 |
+
"pinned": false,
|
| 1965 |
+
"startingTimeGrain": null,
|
| 1966 |
+
"room": null
|
| 1967 |
+
},
|
| 1968 |
+
{
|
| 1969 |
+
"id": "23",
|
| 1970 |
+
"meeting": "23",
|
| 1971 |
+
"pinned": false,
|
| 1972 |
+
"startingTimeGrain": null,
|
| 1973 |
+
"room": null
|
| 1974 |
+
}
|
| 1975 |
+
],
|
| 1976 |
+
"score": null,
|
| 1977 |
+
"solverStatus": null
|
| 1978 |
+
}
|
logging.conf
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[loggers]
|
| 2 |
+
keys=root,timefold
|
| 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]
|
| 15 |
+
level=INFO
|
| 16 |
+
handlers=consoleHandler
|
| 17 |
+
qualname=timefold
|
| 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 |
+
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
|
| 28 |
+
datefmt=%Y-%m-%d %H:%M:%S
|
pyproject.toml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["hatchling"]
|
| 3 |
+
build-backend = "hatchling.build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "meeting_scheduling"
|
| 7 |
+
version = "1.0.0"
|
| 8 |
+
requires-python = ">=3.10"
|
| 9 |
+
dependencies = [
|
| 10 |
+
'solverforge-legacy == 1.24.1',
|
| 11 |
+
'fastapi == 0.111.0',
|
| 12 |
+
'pydantic == 2.7.3',
|
| 13 |
+
'uvicorn == 0.30.1',
|
| 14 |
+
'pytest == 8.2.2',
|
| 15 |
+
'httpx == 0.27.0',
|
| 16 |
+
]
|
| 17 |
+
|
| 18 |
+
[project.scripts]
|
| 19 |
+
run-app = "meeting_scheduling:main"
|
src/meeting_scheduling/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uvicorn
|
| 2 |
+
from .rest_api import app as app
|
| 3 |
+
|
| 4 |
+
def main():
|
| 5 |
+
config = uvicorn.Config("meeting_scheduling:app",
|
| 6 |
+
port=8080,
|
| 7 |
+
log_config="logging.conf",
|
| 8 |
+
use_colors=True)
|
| 9 |
+
server = uvicorn.Server(config)
|
| 10 |
+
server.run()
|
| 11 |
+
|
| 12 |
+
if __name__ == "__main__":
|
| 13 |
+
main()
|
src/meeting_scheduling/constraints.py
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from solverforge_legacy.solver.score import (
|
| 2 |
+
constraint_provider,
|
| 3 |
+
HardMediumSoftScore,
|
| 4 |
+
Joiners,
|
| 5 |
+
ConstraintFactory,
|
| 6 |
+
Constraint,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
from .domain import (
|
| 10 |
+
Attendance,
|
| 11 |
+
MeetingAssignment,
|
| 12 |
+
PreferredAttendance,
|
| 13 |
+
RequiredAttendance,
|
| 14 |
+
Room,
|
| 15 |
+
TimeGrain,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@constraint_provider
|
| 20 |
+
def define_constraints(constraint_factory: ConstraintFactory):
|
| 21 |
+
"""
|
| 22 |
+
Defines all constraints for the meeting scheduling problem, organized by priority (hard, medium, soft).
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 26 |
+
Returns:
|
| 27 |
+
List[Constraint]: All defined constraints.
|
| 28 |
+
"""
|
| 29 |
+
return [
|
| 30 |
+
# Hard constraints
|
| 31 |
+
room_conflict(constraint_factory),
|
| 32 |
+
avoid_overtime(constraint_factory),
|
| 33 |
+
required_attendance_conflict(constraint_factory),
|
| 34 |
+
required_room_capacity(constraint_factory),
|
| 35 |
+
start_and_end_on_same_day(constraint_factory),
|
| 36 |
+
# Medium constraints
|
| 37 |
+
required_and_preferred_attendance_conflict(constraint_factory),
|
| 38 |
+
preferred_attendance_conflict(constraint_factory),
|
| 39 |
+
# Soft constraints
|
| 40 |
+
do_meetings_as_soon_as_possible(constraint_factory),
|
| 41 |
+
one_break_between_consecutive_meetings(constraint_factory),
|
| 42 |
+
overlapping_meetings(constraint_factory),
|
| 43 |
+
assign_larger_rooms_first(constraint_factory),
|
| 44 |
+
room_stability(constraint_factory),
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ************************************************************************
|
| 49 |
+
# Hard constraints
|
| 50 |
+
# ************************************************************************
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def room_conflict(constraint_factory: ConstraintFactory) -> Constraint:
|
| 54 |
+
"""
|
| 55 |
+
Hard constraint: Prevents overlapping meetings in the same room.
|
| 56 |
+
|
| 57 |
+
Penalizes pairs of meetings scheduled in the same room whose time slots overlap, with penalty proportional to the overlap duration.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 61 |
+
Returns:
|
| 62 |
+
Constraint: The defined constraint.
|
| 63 |
+
"""
|
| 64 |
+
return (
|
| 65 |
+
constraint_factory.for_each_unique_pair(
|
| 66 |
+
MeetingAssignment,
|
| 67 |
+
Joiners.equal(lambda assignment: assignment.room),
|
| 68 |
+
Joiners.overlapping(
|
| 69 |
+
lambda assignment: assignment.get_grain_index(),
|
| 70 |
+
lambda assignment: assignment.get_last_time_grain_index() + 1,
|
| 71 |
+
),
|
| 72 |
+
)
|
| 73 |
+
.penalize(
|
| 74 |
+
HardMediumSoftScore.ONE_HARD,
|
| 75 |
+
lambda left_assignment,
|
| 76 |
+
right_assignment: right_assignment.calculate_overlap(left_assignment),
|
| 77 |
+
)
|
| 78 |
+
.as_constraint("Room conflict")
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def avoid_overtime(constraint_factory: ConstraintFactory) -> Constraint:
|
| 83 |
+
"""
|
| 84 |
+
Hard constraint: Prevents meetings from extending beyond available time slots.
|
| 85 |
+
|
| 86 |
+
Penalizes meetings that end after the last available time grain, based on how far they extend beyond the schedule.
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 90 |
+
Returns:
|
| 91 |
+
Constraint: The defined constraint.
|
| 92 |
+
"""
|
| 93 |
+
return (
|
| 94 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment)
|
| 95 |
+
.filter(
|
| 96 |
+
lambda meeting_assignment: meeting_assignment.starting_time_grain
|
| 97 |
+
is not None
|
| 98 |
+
)
|
| 99 |
+
.if_not_exists(
|
| 100 |
+
TimeGrain,
|
| 101 |
+
Joiners.equal(
|
| 102 |
+
lambda assignment: assignment.get_last_time_grain_index(),
|
| 103 |
+
lambda time_grain: time_grain.grain_index,
|
| 104 |
+
),
|
| 105 |
+
)
|
| 106 |
+
.penalize(
|
| 107 |
+
HardMediumSoftScore.ONE_HARD,
|
| 108 |
+
lambda meeting_assignment: meeting_assignment.get_last_time_grain_index(),
|
| 109 |
+
)
|
| 110 |
+
.as_constraint("Don't go in overtime")
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def required_attendance_conflict(constraint_factory: ConstraintFactory) -> Constraint:
|
| 115 |
+
"""
|
| 116 |
+
Hard constraint: Prevents required attendees from having overlapping meetings.
|
| 117 |
+
|
| 118 |
+
Penalizes when a person required at multiple meetings is scheduled for overlapping meetings, proportional to the overlap duration.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 122 |
+
Returns:
|
| 123 |
+
Constraint: The defined constraint.
|
| 124 |
+
"""
|
| 125 |
+
return (
|
| 126 |
+
constraint_factory.for_each_unique_pair(
|
| 127 |
+
RequiredAttendance, Joiners.equal(lambda attendance: attendance.person)
|
| 128 |
+
)
|
| 129 |
+
.join(
|
| 130 |
+
MeetingAssignment,
|
| 131 |
+
Joiners.equal(
|
| 132 |
+
lambda left_required, right_required: left_required.meeting_id,
|
| 133 |
+
lambda assignment: assignment.meeting.id,
|
| 134 |
+
),
|
| 135 |
+
)
|
| 136 |
+
.join(
|
| 137 |
+
MeetingAssignment,
|
| 138 |
+
Joiners.equal(
|
| 139 |
+
lambda left_required,
|
| 140 |
+
right_required,
|
| 141 |
+
left_assignment: right_required.meeting_id,
|
| 142 |
+
lambda assignment: assignment.meeting.id,
|
| 143 |
+
),
|
| 144 |
+
Joiners.overlapping(
|
| 145 |
+
lambda attendee1, attendee2, assignment: assignment.get_grain_index(),
|
| 146 |
+
lambda attendee1,
|
| 147 |
+
attendee2,
|
| 148 |
+
assignment: assignment.get_last_time_grain_index() + 1,
|
| 149 |
+
lambda assignment: assignment.get_grain_index(),
|
| 150 |
+
lambda assignment: assignment.get_last_time_grain_index() + 1,
|
| 151 |
+
),
|
| 152 |
+
)
|
| 153 |
+
.penalize(
|
| 154 |
+
HardMediumSoftScore.ONE_HARD,
|
| 155 |
+
lambda left_required,
|
| 156 |
+
right_required,
|
| 157 |
+
left_assignment,
|
| 158 |
+
right_assignment: right_assignment.calculate_overlap(left_assignment),
|
| 159 |
+
)
|
| 160 |
+
.as_constraint("Required attendance conflict")
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def required_room_capacity(constraint_factory: ConstraintFactory) -> Constraint:
|
| 165 |
+
"""
|
| 166 |
+
Hard constraint: Ensures rooms have enough capacity for required attendees.
|
| 167 |
+
|
| 168 |
+
Penalizes meetings assigned to rooms with insufficient capacity, proportional to the shortfall.
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 172 |
+
Returns:
|
| 173 |
+
Constraint: The defined constraint.
|
| 174 |
+
"""
|
| 175 |
+
return (
|
| 176 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment)
|
| 177 |
+
.filter(
|
| 178 |
+
lambda meeting_assignment: meeting_assignment.get_required_capacity()
|
| 179 |
+
> meeting_assignment.get_room_capacity()
|
| 180 |
+
)
|
| 181 |
+
.penalize(
|
| 182 |
+
HardMediumSoftScore.ONE_HARD,
|
| 183 |
+
lambda meeting_assignment: meeting_assignment.get_required_capacity()
|
| 184 |
+
- meeting_assignment.get_room_capacity(),
|
| 185 |
+
)
|
| 186 |
+
.as_constraint("Required room capacity")
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def start_and_end_on_same_day(constraint_factory: ConstraintFactory) -> Constraint:
|
| 191 |
+
"""
|
| 192 |
+
Hard constraint: Ensures meetings start and end on the same day.
|
| 193 |
+
|
| 194 |
+
Penalizes meetings that span multiple days.
|
| 195 |
+
|
| 196 |
+
Args:
|
| 197 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 198 |
+
Returns:
|
| 199 |
+
Constraint: The defined constraint.
|
| 200 |
+
"""
|
| 201 |
+
return (
|
| 202 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment)
|
| 203 |
+
.filter(
|
| 204 |
+
lambda meeting_assignment: meeting_assignment.starting_time_grain
|
| 205 |
+
is not None
|
| 206 |
+
)
|
| 207 |
+
.join(
|
| 208 |
+
TimeGrain,
|
| 209 |
+
Joiners.equal(
|
| 210 |
+
lambda meeting_assignment: meeting_assignment.get_last_time_grain_index(),
|
| 211 |
+
lambda time_grain: time_grain.grain_index,
|
| 212 |
+
),
|
| 213 |
+
Joiners.filtering(
|
| 214 |
+
lambda meeting_assignment,
|
| 215 |
+
time_grain: meeting_assignment.starting_time_grain.day_of_year
|
| 216 |
+
!= time_grain.day_of_year
|
| 217 |
+
),
|
| 218 |
+
)
|
| 219 |
+
.penalize(HardMediumSoftScore.ONE_HARD)
|
| 220 |
+
.as_constraint("Start and end on same day")
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ************************************************************************
|
| 225 |
+
# Medium constraints
|
| 226 |
+
# ************************************************************************
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def required_and_preferred_attendance_conflict(
|
| 230 |
+
constraint_factory: ConstraintFactory,
|
| 231 |
+
) -> Constraint:
|
| 232 |
+
"""
|
| 233 |
+
Medium constraint: Discourages conflicts between required and preferred attendance for the same person.
|
| 234 |
+
|
| 235 |
+
Penalizes when a person required at one meeting and preferred at another is scheduled for overlapping meetings, proportional to the overlap duration.
|
| 236 |
+
|
| 237 |
+
Args:
|
| 238 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 239 |
+
Returns:
|
| 240 |
+
Constraint: The defined constraint.
|
| 241 |
+
"""
|
| 242 |
+
return (
|
| 243 |
+
constraint_factory.for_each(RequiredAttendance)
|
| 244 |
+
.join(
|
| 245 |
+
PreferredAttendance,
|
| 246 |
+
Joiners.equal(
|
| 247 |
+
lambda required: required.person, lambda preferred: preferred.person
|
| 248 |
+
),
|
| 249 |
+
)
|
| 250 |
+
.join(
|
| 251 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
|
| 252 |
+
lambda assignment: assignment.starting_time_grain is not None
|
| 253 |
+
),
|
| 254 |
+
Joiners.equal(
|
| 255 |
+
lambda required, preferred: required.meeting_id,
|
| 256 |
+
lambda assignment: assignment.meeting.id,
|
| 257 |
+
),
|
| 258 |
+
)
|
| 259 |
+
.join(
|
| 260 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
|
| 261 |
+
lambda assignment: assignment.starting_time_grain is not None
|
| 262 |
+
),
|
| 263 |
+
Joiners.equal(
|
| 264 |
+
lambda required, preferred, left_assignment: preferred.meeting_id,
|
| 265 |
+
lambda assignment: assignment.meeting.id,
|
| 266 |
+
),
|
| 267 |
+
Joiners.overlapping(
|
| 268 |
+
lambda required, preferred, assignment: assignment.get_grain_index(),
|
| 269 |
+
lambda required,
|
| 270 |
+
preferred,
|
| 271 |
+
assignment: assignment.get_last_time_grain_index() + 1,
|
| 272 |
+
lambda assignment: assignment.get_grain_index(),
|
| 273 |
+
lambda assignment: assignment.get_last_time_grain_index() + 1,
|
| 274 |
+
),
|
| 275 |
+
)
|
| 276 |
+
.penalize(
|
| 277 |
+
HardMediumSoftScore.ONE_MEDIUM,
|
| 278 |
+
lambda required,
|
| 279 |
+
preferred,
|
| 280 |
+
left_assignment,
|
| 281 |
+
right_assignment: right_assignment.calculate_overlap(left_assignment),
|
| 282 |
+
)
|
| 283 |
+
.as_constraint("Required and preferred attendance conflict")
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def preferred_attendance_conflict(constraint_factory: ConstraintFactory) -> Constraint:
|
| 288 |
+
"""
|
| 289 |
+
Medium constraint: Discourages conflicts between preferred attendees.
|
| 290 |
+
|
| 291 |
+
Penalizes when a person preferred at multiple meetings is scheduled for overlapping meetings, proportional to the overlap duration.
|
| 292 |
+
|
| 293 |
+
Args:
|
| 294 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 295 |
+
Returns:
|
| 296 |
+
Constraint: The defined constraint.
|
| 297 |
+
"""
|
| 298 |
+
return (
|
| 299 |
+
constraint_factory.for_each_unique_pair(
|
| 300 |
+
PreferredAttendance, Joiners.equal(lambda attendance: attendance.person)
|
| 301 |
+
)
|
| 302 |
+
.join(
|
| 303 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
|
| 304 |
+
lambda assignment: assignment.starting_time_grain is not None
|
| 305 |
+
),
|
| 306 |
+
Joiners.equal(
|
| 307 |
+
lambda left_attendance, right_attendance: left_attendance.meeting_id,
|
| 308 |
+
lambda assignment: assignment.meeting.id,
|
| 309 |
+
),
|
| 310 |
+
)
|
| 311 |
+
.join(
|
| 312 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
|
| 313 |
+
lambda assignment: assignment.starting_time_grain is not None
|
| 314 |
+
),
|
| 315 |
+
Joiners.equal(
|
| 316 |
+
lambda left_attendance,
|
| 317 |
+
right_attendance,
|
| 318 |
+
left_assignment: right_attendance.meeting_id,
|
| 319 |
+
lambda assignment: assignment.meeting.id,
|
| 320 |
+
),
|
| 321 |
+
Joiners.overlapping(
|
| 322 |
+
lambda attendee1, attendee2, assignment: assignment.get_grain_index(),
|
| 323 |
+
lambda attendee1,
|
| 324 |
+
attendee2,
|
| 325 |
+
assignment: assignment.get_last_time_grain_index() + 1,
|
| 326 |
+
lambda assignment: assignment.get_grain_index(),
|
| 327 |
+
lambda assignment: assignment.get_last_time_grain_index() + 1,
|
| 328 |
+
),
|
| 329 |
+
)
|
| 330 |
+
.penalize(
|
| 331 |
+
HardMediumSoftScore.ONE_MEDIUM,
|
| 332 |
+
lambda left_attendance,
|
| 333 |
+
right_attendance,
|
| 334 |
+
left_assignment,
|
| 335 |
+
right_assignment: right_assignment.calculate_overlap(left_assignment),
|
| 336 |
+
)
|
| 337 |
+
.as_constraint("Preferred attendance conflict")
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
# ************************************************************************
|
| 342 |
+
# Soft constraints
|
| 343 |
+
# ************************************************************************
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def do_meetings_as_soon_as_possible(
|
| 347 |
+
constraint_factory: ConstraintFactory,
|
| 348 |
+
) -> Constraint:
|
| 349 |
+
"""
|
| 350 |
+
Soft constraint: Encourages scheduling meetings earlier in the available time slots.
|
| 351 |
+
|
| 352 |
+
Penalizes meetings scheduled later in the available time grains, proportional to their end time.
|
| 353 |
+
|
| 354 |
+
Args:
|
| 355 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 356 |
+
Returns:
|
| 357 |
+
Constraint: The defined constraint.
|
| 358 |
+
"""
|
| 359 |
+
return (
|
| 360 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment)
|
| 361 |
+
.filter(
|
| 362 |
+
lambda meeting_assignment: meeting_assignment.starting_time_grain
|
| 363 |
+
is not None
|
| 364 |
+
)
|
| 365 |
+
.penalize(
|
| 366 |
+
HardMediumSoftScore.ONE_SOFT,
|
| 367 |
+
lambda meeting_assignment: meeting_assignment.get_last_time_grain_index(),
|
| 368 |
+
)
|
| 369 |
+
.as_constraint("Do all meetings as soon as possible")
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def one_break_between_consecutive_meetings(
|
| 374 |
+
constraint_factory: ConstraintFactory,
|
| 375 |
+
) -> Constraint:
|
| 376 |
+
"""
|
| 377 |
+
Soft constraint: Penalizes consecutive meetings without a break.
|
| 378 |
+
|
| 379 |
+
Penalizes pairs of meetings that are scheduled consecutively without at least one time grain break between them.
|
| 380 |
+
|
| 381 |
+
Args:
|
| 382 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 383 |
+
Returns:
|
| 384 |
+
Constraint: The defined constraint.
|
| 385 |
+
"""
|
| 386 |
+
return (
|
| 387 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment)
|
| 388 |
+
.filter(
|
| 389 |
+
lambda meeting_assignment: meeting_assignment.starting_time_grain
|
| 390 |
+
is not None
|
| 391 |
+
)
|
| 392 |
+
.join(
|
| 393 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
|
| 394 |
+
lambda assignment: assignment.starting_time_grain is not None
|
| 395 |
+
),
|
| 396 |
+
Joiners.equal(
|
| 397 |
+
lambda left_assignment: left_assignment.get_last_time_grain_index(),
|
| 398 |
+
lambda right_assignment: right_assignment.get_grain_index() - 1,
|
| 399 |
+
),
|
| 400 |
+
)
|
| 401 |
+
.penalize(HardMediumSoftScore.of_soft(100))
|
| 402 |
+
.as_constraint("One TimeGrain break between two consecutive meetings")
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def overlapping_meetings(constraint_factory: ConstraintFactory) -> Constraint:
|
| 407 |
+
"""
|
| 408 |
+
Soft constraint: Discourages overlapping meetings, even in different rooms.
|
| 409 |
+
|
| 410 |
+
Penalizes pairs of meetings that overlap in time, regardless of room, proportional to the overlap duration.
|
| 411 |
+
|
| 412 |
+
Args:
|
| 413 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 414 |
+
Returns:
|
| 415 |
+
Constraint: The defined constraint.
|
| 416 |
+
"""
|
| 417 |
+
return (
|
| 418 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment)
|
| 419 |
+
.filter(
|
| 420 |
+
lambda meeting_assignment: meeting_assignment.starting_time_grain
|
| 421 |
+
is not None
|
| 422 |
+
)
|
| 423 |
+
.join(
|
| 424 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment).filter(
|
| 425 |
+
lambda meeting_assignment: meeting_assignment.starting_time_grain
|
| 426 |
+
is not None
|
| 427 |
+
),
|
| 428 |
+
Joiners.greater_than(
|
| 429 |
+
lambda left_assignment: left_assignment.meeting.id,
|
| 430 |
+
lambda right_assignment: right_assignment.meeting.id,
|
| 431 |
+
),
|
| 432 |
+
Joiners.overlapping(
|
| 433 |
+
lambda assignment: assignment.get_grain_index(),
|
| 434 |
+
lambda assignment: assignment.get_last_time_grain_index() + 1,
|
| 435 |
+
),
|
| 436 |
+
)
|
| 437 |
+
.penalize(
|
| 438 |
+
HardMediumSoftScore.of_soft(10),
|
| 439 |
+
lambda left_assignment, right_assignment: left_assignment.calculate_overlap(
|
| 440 |
+
right_assignment
|
| 441 |
+
),
|
| 442 |
+
)
|
| 443 |
+
.as_constraint("Overlapping meetings")
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def assign_larger_rooms_first(constraint_factory: ConstraintFactory) -> Constraint:
|
| 448 |
+
"""
|
| 449 |
+
Soft constraint: Penalizes using smaller rooms when larger rooms are available.
|
| 450 |
+
|
| 451 |
+
Penalizes when a meeting is assigned to a room while larger rooms exist, proportional to the capacity difference.
|
| 452 |
+
|
| 453 |
+
Args:
|
| 454 |
+
constraint_factory (ConstraintFactory): The constraint factory.
|
| 455 |
+
Returns:
|
| 456 |
+
Constraint: The defined constraint.
|
| 457 |
+
"""
|
| 458 |
+
return (
|
| 459 |
+
constraint_factory.for_each_including_unassigned(MeetingAssignment)
|
| 460 |
+
.filter(lambda meeting_assignment: meeting_assignment.room is not None)
|
| 461 |
+
.join(
|
| 462 |
+
Room,
|
| 463 |
+
Joiners.less_than(
|
| 464 |
+
lambda meeting_assignment: meeting_assignment.get_room_capacity(),
|
| 465 |
+
lambda room: room.capacity,
|
| 466 |
+
),
|
| 467 |
+
)
|
| 468 |
+
.penalize(
|
| 469 |
+
HardMediumSoftScore.ONE_SOFT,
|
| 470 |
+
lambda meeting_assignment, room: room.capacity
|
| 471 |
+
- meeting_assignment.get_room_capacity(),
|
| 472 |
+
)
|
| 473 |
+
.as_constraint("Assign larger rooms first")
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def room_stability(constraint_factory: ConstraintFactory) -> Constraint:
|
| 478 |
+
"""
|
| 479 |
+
Soft constraint: Encourages room stability for people attending multiple meetings.
|
| 480 |
+
|
| 481 |
+
Penalizes when a person attends meetings in different rooms that are close in time.
|
| 482 |
+
Uses weighted penalty: back-to-back room switches cost more than switches with gaps.
|
| 483 |
+
"""
|
| 484 |
+
return (
|
| 485 |
+
constraint_factory.for_each(Attendance)
|
| 486 |
+
.join(
|
| 487 |
+
Attendance,
|
| 488 |
+
Joiners.equal(lambda a: a.person),
|
| 489 |
+
Joiners.filtering(lambda left, right: left.meeting_id != right.meeting_id),
|
| 490 |
+
)
|
| 491 |
+
.join(
|
| 492 |
+
MeetingAssignment,
|
| 493 |
+
Joiners.equal(
|
| 494 |
+
lambda left, right: left.meeting_id,
|
| 495 |
+
lambda assignment: assignment.meeting.id,
|
| 496 |
+
),
|
| 497 |
+
)
|
| 498 |
+
.join(
|
| 499 |
+
MeetingAssignment,
|
| 500 |
+
Joiners.equal(
|
| 501 |
+
lambda left, right, left_assignment: right.meeting_id,
|
| 502 |
+
lambda assignment: assignment.meeting.id,
|
| 503 |
+
),
|
| 504 |
+
Joiners.less_than(
|
| 505 |
+
lambda left, right, left_assignment: left_assignment.get_grain_index(),
|
| 506 |
+
lambda assignment: assignment.get_grain_index(),
|
| 507 |
+
),
|
| 508 |
+
Joiners.filtering(
|
| 509 |
+
lambda left, right, left_assignment, right_assignment: left_assignment.room != right_assignment.room
|
| 510 |
+
),
|
| 511 |
+
Joiners.filtering(
|
| 512 |
+
lambda left, right, left_assignment, right_assignment: right_assignment.get_grain_index()
|
| 513 |
+
- left_assignment.meeting.duration_in_grains
|
| 514 |
+
- left_assignment.get_grain_index()
|
| 515 |
+
<= 2
|
| 516 |
+
),
|
| 517 |
+
)
|
| 518 |
+
.penalize(
|
| 519 |
+
HardMediumSoftScore.ONE_SOFT,
|
| 520 |
+
lambda left, right, left_assignment, right_assignment: 3 - (
|
| 521 |
+
right_assignment.get_grain_index()
|
| 522 |
+
- left_assignment.meeting.duration_in_grains
|
| 523 |
+
- left_assignment.get_grain_index()
|
| 524 |
+
),
|
| 525 |
+
)
|
| 526 |
+
.as_constraint("Room stability")
|
| 527 |
+
)
|
src/meeting_scheduling/converters.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Optional, Union
|
| 2 |
+
from . import domain
|
| 3 |
+
from .json_serialization import JsonDomainBase
|
| 4 |
+
from pydantic import Field
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
# Pydantic models for API boundary
|
| 8 |
+
class PersonModel(JsonDomainBase):
|
| 9 |
+
id: str
|
| 10 |
+
full_name: str
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class TimeGrainModel(JsonDomainBase):
|
| 14 |
+
id: str
|
| 15 |
+
grain_index: int
|
| 16 |
+
day_of_year: int
|
| 17 |
+
starting_minute_of_day: int
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class RoomModel(JsonDomainBase):
|
| 21 |
+
id: str
|
| 22 |
+
name: str
|
| 23 |
+
capacity: int
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class RequiredAttendanceModel(JsonDomainBase):
|
| 27 |
+
id: str
|
| 28 |
+
person: PersonModel
|
| 29 |
+
meeting_id: str = Field(..., alias="meeting")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class PreferredAttendanceModel(JsonDomainBase):
|
| 33 |
+
id: str
|
| 34 |
+
person: PersonModel
|
| 35 |
+
meeting_id: str = Field(..., alias="meeting")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class MeetingModel(JsonDomainBase):
|
| 39 |
+
id: str
|
| 40 |
+
topic: str
|
| 41 |
+
duration_in_grains: int
|
| 42 |
+
speakers: Optional[List[PersonModel]] = None
|
| 43 |
+
content: Optional[str] = None
|
| 44 |
+
entire_group_meeting: bool = False
|
| 45 |
+
required_attendances: List[RequiredAttendanceModel] = Field(
|
| 46 |
+
default_factory=list, alias="requiredAttendances"
|
| 47 |
+
)
|
| 48 |
+
preferred_attendances: List[PreferredAttendanceModel] = Field(
|
| 49 |
+
default_factory=list, alias="preferredAttendances"
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class MeetingAssignmentModel(JsonDomainBase):
|
| 54 |
+
id: str
|
| 55 |
+
meeting: Union[str, MeetingModel]
|
| 56 |
+
pinned: bool = False
|
| 57 |
+
starting_time_grain: Union[str, TimeGrainModel, None] = None
|
| 58 |
+
room: Union[str, RoomModel, None] = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class MeetingScheduleModel(JsonDomainBase):
|
| 62 |
+
people: List[PersonModel]
|
| 63 |
+
time_grains: List[TimeGrainModel] = Field(..., alias="timeGrains")
|
| 64 |
+
rooms: List[RoomModel]
|
| 65 |
+
meetings: List[MeetingModel]
|
| 66 |
+
required_attendances: List[RequiredAttendanceModel] = Field(
|
| 67 |
+
default_factory=list, alias="requiredAttendances"
|
| 68 |
+
)
|
| 69 |
+
preferred_attendances: List[PreferredAttendanceModel] = Field(
|
| 70 |
+
default_factory=list, alias="preferredAttendances"
|
| 71 |
+
)
|
| 72 |
+
meeting_assignments: List[MeetingAssignmentModel] = Field(
|
| 73 |
+
default_factory=list, alias="meetingAssignments"
|
| 74 |
+
)
|
| 75 |
+
score: Optional[str] = None
|
| 76 |
+
solver_status: Optional[str] = None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# Conversion functions from domain to API models
|
| 80 |
+
def person_to_model(person: domain.Person) -> PersonModel:
|
| 81 |
+
return PersonModel(id=person.id, full_name=person.full_name)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def time_grain_to_model(time_grain: domain.TimeGrain) -> TimeGrainModel:
|
| 85 |
+
return TimeGrainModel(
|
| 86 |
+
id=time_grain.id,
|
| 87 |
+
grain_index=time_grain.grain_index,
|
| 88 |
+
day_of_year=time_grain.day_of_year,
|
| 89 |
+
starting_minute_of_day=time_grain.starting_minute_of_day,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def room_to_model(room: domain.Room) -> RoomModel:
|
| 94 |
+
return RoomModel(id=room.id, name=room.name, capacity=room.capacity)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def required_attendance_to_model(
|
| 98 |
+
attendance: domain.RequiredAttendance,
|
| 99 |
+
) -> RequiredAttendanceModel:
|
| 100 |
+
return RequiredAttendanceModel(
|
| 101 |
+
id=attendance.id,
|
| 102 |
+
person=person_to_model(attendance.person),
|
| 103 |
+
meeting_id=attendance.meeting_id,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def preferred_attendance_to_model(
|
| 108 |
+
attendance: domain.PreferredAttendance,
|
| 109 |
+
) -> PreferredAttendanceModel:
|
| 110 |
+
return PreferredAttendanceModel(
|
| 111 |
+
id=attendance.id,
|
| 112 |
+
person=person_to_model(attendance.person),
|
| 113 |
+
meeting_id=attendance.meeting_id,
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def meeting_to_model(meeting: domain.Meeting) -> MeetingModel:
|
| 118 |
+
return MeetingModel(
|
| 119 |
+
id=meeting.id,
|
| 120 |
+
topic=meeting.topic,
|
| 121 |
+
duration_in_grains=meeting.duration_in_grains,
|
| 122 |
+
speakers=[person_to_model(p) for p in meeting.speakers]
|
| 123 |
+
if meeting.speakers
|
| 124 |
+
else None,
|
| 125 |
+
content=meeting.content,
|
| 126 |
+
entire_group_meeting=meeting.entire_group_meeting,
|
| 127 |
+
required_attendances=[
|
| 128 |
+
required_attendance_to_model(ra) for ra in meeting.required_attendances
|
| 129 |
+
],
|
| 130 |
+
preferred_attendances=[
|
| 131 |
+
preferred_attendance_to_model(pa) for pa in meeting.preferred_attendances
|
| 132 |
+
],
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def meeting_assignment_to_model(
|
| 137 |
+
assignment: domain.MeetingAssignment,
|
| 138 |
+
) -> MeetingAssignmentModel:
|
| 139 |
+
return MeetingAssignmentModel(
|
| 140 |
+
id=assignment.id,
|
| 141 |
+
meeting=meeting_to_model(assignment.meeting),
|
| 142 |
+
pinned=assignment.pinned,
|
| 143 |
+
starting_time_grain=time_grain_to_model(assignment.starting_time_grain)
|
| 144 |
+
if assignment.starting_time_grain
|
| 145 |
+
else None,
|
| 146 |
+
room=room_to_model(assignment.room) if assignment.room else None,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def schedule_to_model(schedule: domain.MeetingSchedule) -> MeetingScheduleModel:
|
| 151 |
+
return MeetingScheduleModel(
|
| 152 |
+
people=[person_to_model(p) for p in schedule.people],
|
| 153 |
+
time_grains=[time_grain_to_model(tg) for tg in schedule.time_grains],
|
| 154 |
+
rooms=[room_to_model(r) for r in schedule.rooms],
|
| 155 |
+
meetings=[meeting_to_model(m) for m in schedule.meetings],
|
| 156 |
+
required_attendances=[
|
| 157 |
+
required_attendance_to_model(ra) for ra in schedule.required_attendances
|
| 158 |
+
],
|
| 159 |
+
preferred_attendances=[
|
| 160 |
+
preferred_attendance_to_model(pa) for pa in schedule.preferred_attendances
|
| 161 |
+
],
|
| 162 |
+
meeting_assignments=[
|
| 163 |
+
meeting_assignment_to_model(ma) for ma in schedule.meeting_assignments
|
| 164 |
+
],
|
| 165 |
+
score=str(schedule.score) if schedule.score else None,
|
| 166 |
+
solver_status=schedule.solver_status.name if schedule.solver_status else None,
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# Conversion functions from API models to domain
|
| 171 |
+
def model_to_person(model: PersonModel) -> domain.Person:
|
| 172 |
+
return domain.Person(id=model.id, full_name=model.full_name)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def model_to_time_grain(model: TimeGrainModel) -> domain.TimeGrain:
|
| 176 |
+
return domain.TimeGrain(
|
| 177 |
+
id=model.id,
|
| 178 |
+
grain_index=model.grain_index,
|
| 179 |
+
day_of_year=model.day_of_year,
|
| 180 |
+
starting_minute_of_day=model.starting_minute_of_day,
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def model_to_room(model: RoomModel) -> domain.Room:
|
| 185 |
+
return domain.Room(id=model.id, name=model.name, capacity=model.capacity)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def model_to_required_attendance(
|
| 189 |
+
model: RequiredAttendanceModel,
|
| 190 |
+
) -> domain.RequiredAttendance:
|
| 191 |
+
return domain.RequiredAttendance(
|
| 192 |
+
id=model.id, person=model_to_person(model.person), meeting_id=model.meeting_id
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def model_to_preferred_attendance(
|
| 197 |
+
model: PreferredAttendanceModel,
|
| 198 |
+
) -> domain.PreferredAttendance:
|
| 199 |
+
return domain.PreferredAttendance(
|
| 200 |
+
id=model.id, person=model_to_person(model.person), meeting_id=model.meeting_id
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def model_to_meeting(model: MeetingModel) -> domain.Meeting:
|
| 205 |
+
return domain.Meeting(
|
| 206 |
+
id=model.id,
|
| 207 |
+
topic=model.topic,
|
| 208 |
+
duration_in_grains=model.duration_in_grains,
|
| 209 |
+
speakers=[model_to_person(p) for p in model.speakers]
|
| 210 |
+
if model.speakers
|
| 211 |
+
else None,
|
| 212 |
+
content=model.content,
|
| 213 |
+
entire_group_meeting=model.entire_group_meeting,
|
| 214 |
+
required_attendances=[
|
| 215 |
+
model_to_required_attendance(ra) for ra in model.required_attendances
|
| 216 |
+
],
|
| 217 |
+
preferred_attendances=[
|
| 218 |
+
model_to_preferred_attendance(pa) for pa in model.preferred_attendances
|
| 219 |
+
],
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def model_to_meeting_assignment(
|
| 224 |
+
model: MeetingAssignmentModel,
|
| 225 |
+
meeting_lookup: dict,
|
| 226 |
+
room_lookup: dict,
|
| 227 |
+
time_grain_lookup: dict,
|
| 228 |
+
) -> domain.MeetingAssignment:
|
| 229 |
+
# Handle meeting reference
|
| 230 |
+
if isinstance(model.meeting, str):
|
| 231 |
+
meeting = meeting_lookup[model.meeting]
|
| 232 |
+
else:
|
| 233 |
+
meeting = model_to_meeting(model.meeting)
|
| 234 |
+
|
| 235 |
+
# Handle room reference
|
| 236 |
+
room = None
|
| 237 |
+
if model.room:
|
| 238 |
+
if isinstance(model.room, str):
|
| 239 |
+
room = room_lookup[model.room]
|
| 240 |
+
else:
|
| 241 |
+
room = model_to_room(model.room)
|
| 242 |
+
|
| 243 |
+
# Handle time grain reference
|
| 244 |
+
starting_time_grain = None
|
| 245 |
+
if model.starting_time_grain:
|
| 246 |
+
if isinstance(model.starting_time_grain, str):
|
| 247 |
+
starting_time_grain = time_grain_lookup[model.starting_time_grain]
|
| 248 |
+
else:
|
| 249 |
+
starting_time_grain = model_to_time_grain(model.starting_time_grain)
|
| 250 |
+
|
| 251 |
+
return domain.MeetingAssignment(
|
| 252 |
+
id=model.id,
|
| 253 |
+
meeting=meeting,
|
| 254 |
+
pinned=model.pinned,
|
| 255 |
+
starting_time_grain=starting_time_grain,
|
| 256 |
+
room=room,
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def model_to_schedule(model: MeetingScheduleModel) -> domain.MeetingSchedule:
|
| 261 |
+
# Convert basic collections first
|
| 262 |
+
people = [model_to_person(p) for p in model.people]
|
| 263 |
+
time_grains = [model_to_time_grain(tg) for tg in model.time_grains]
|
| 264 |
+
rooms = [model_to_room(r) for r in model.rooms]
|
| 265 |
+
meetings = [model_to_meeting(m) for m in model.meetings]
|
| 266 |
+
|
| 267 |
+
# Create lookup dictionaries for references
|
| 268 |
+
meeting_lookup = {m.id: m for m in meetings}
|
| 269 |
+
room_lookup = {r.id: r for r in rooms}
|
| 270 |
+
time_grain_lookup = {tg.id: tg for tg in time_grains}
|
| 271 |
+
|
| 272 |
+
# Convert meeting assignments with lookups
|
| 273 |
+
meeting_assignments = [
|
| 274 |
+
model_to_meeting_assignment(ma, meeting_lookup, room_lookup, time_grain_lookup)
|
| 275 |
+
for ma in model.meeting_assignments
|
| 276 |
+
]
|
| 277 |
+
|
| 278 |
+
# Convert attendances
|
| 279 |
+
required_attendances = [
|
| 280 |
+
model_to_required_attendance(ra) for ra in model.required_attendances
|
| 281 |
+
]
|
| 282 |
+
preferred_attendances = [
|
| 283 |
+
model_to_preferred_attendance(pa) for pa in model.preferred_attendances
|
| 284 |
+
]
|
| 285 |
+
|
| 286 |
+
# Build combined attendances list for room_stability constraint
|
| 287 |
+
people_lookup = {p.id: p for p in people}
|
| 288 |
+
attendances = []
|
| 289 |
+
for ra in required_attendances:
|
| 290 |
+
attendances.append(domain.Attendance(
|
| 291 |
+
id=ra.id,
|
| 292 |
+
person=ra.person,
|
| 293 |
+
meeting_id=ra.meeting_id,
|
| 294 |
+
))
|
| 295 |
+
for pa in preferred_attendances:
|
| 296 |
+
attendances.append(domain.Attendance(
|
| 297 |
+
id=pa.id,
|
| 298 |
+
person=pa.person,
|
| 299 |
+
meeting_id=pa.meeting_id,
|
| 300 |
+
))
|
| 301 |
+
|
| 302 |
+
# Handle score
|
| 303 |
+
score = None
|
| 304 |
+
if model.score:
|
| 305 |
+
from solverforge_legacy.solver.score import HardMediumSoftScore
|
| 306 |
+
|
| 307 |
+
score = HardMediumSoftScore.parse(model.score)
|
| 308 |
+
|
| 309 |
+
# Handle solver status
|
| 310 |
+
solver_status = domain.SolverStatus.NOT_SOLVING
|
| 311 |
+
if model.solver_status:
|
| 312 |
+
solver_status = domain.SolverStatus[model.solver_status]
|
| 313 |
+
|
| 314 |
+
return domain.MeetingSchedule(
|
| 315 |
+
people=people,
|
| 316 |
+
time_grains=time_grains,
|
| 317 |
+
rooms=rooms,
|
| 318 |
+
meetings=meetings,
|
| 319 |
+
required_attendances=required_attendances,
|
| 320 |
+
preferred_attendances=preferred_attendances,
|
| 321 |
+
attendances=attendances,
|
| 322 |
+
meeting_assignments=meeting_assignments,
|
| 323 |
+
score=score,
|
| 324 |
+
solver_status=solver_status,
|
| 325 |
+
)
|
src/meeting_scheduling/demo_data.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from random import Random
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from enum import Enum
|
| 4 |
+
from typing import List
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
from .domain import Person, TimeGrain, Room, Meeting, MeetingAssignment, MeetingSchedule, RequiredAttendance, PreferredAttendance
|
| 8 |
+
|
| 9 |
+
class DemoData(str, Enum):
|
| 10 |
+
SMALL = "SMALL"
|
| 11 |
+
MEDIUM = "MEDIUM"
|
| 12 |
+
LARGE = "LARGE"
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True, kw_only=True)
|
| 15 |
+
class CountDistribution:
|
| 16 |
+
count: int
|
| 17 |
+
weight: float
|
| 18 |
+
|
| 19 |
+
def counts(distributions: tuple[CountDistribution, ...]) -> tuple[int, ...]:
|
| 20 |
+
return tuple(distribution.count for distribution in distributions)
|
| 21 |
+
|
| 22 |
+
def weights(distributions: tuple[CountDistribution, ...]) -> tuple[float, ...]:
|
| 23 |
+
return tuple(distribution.weight for distribution in distributions)
|
| 24 |
+
|
| 25 |
+
def generate_demo_data() -> MeetingSchedule:
|
| 26 |
+
"""Generate demo data for the meeting scheduling problem."""
|
| 27 |
+
rnd = Random(0) # For reproducible results
|
| 28 |
+
|
| 29 |
+
# People
|
| 30 |
+
people = generate_people(20, rnd)
|
| 31 |
+
|
| 32 |
+
# Time grains
|
| 33 |
+
time_grains = generate_time_grains()
|
| 34 |
+
|
| 35 |
+
# Rooms
|
| 36 |
+
rooms = [
|
| 37 |
+
Room(id="R1", name="Room 1", capacity=30),
|
| 38 |
+
Room(id="R2", name="Room 2", capacity=20),
|
| 39 |
+
Room(id="R3", name="Room 3", capacity=16)
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
# Meetings
|
| 43 |
+
meetings = generate_meetings(people, rnd)
|
| 44 |
+
|
| 45 |
+
# Rebuild meetings with correct attendances
|
| 46 |
+
all_required_attendances = [ra for meeting in meetings for ra in meeting.required_attendances]
|
| 47 |
+
all_preferred_attendances = [pa for meeting in meetings for pa in meeting.preferred_attendances]
|
| 48 |
+
new_meetings = []
|
| 49 |
+
for m in meetings:
|
| 50 |
+
new_meetings.append(
|
| 51 |
+
type(m)(
|
| 52 |
+
id=m.id,
|
| 53 |
+
topic=m.topic,
|
| 54 |
+
duration_in_grains=m.duration_in_grains,
|
| 55 |
+
speakers=m.speakers,
|
| 56 |
+
content=m.content or "",
|
| 57 |
+
entire_group_meeting=m.entire_group_meeting,
|
| 58 |
+
required_attendances=[a for a in all_required_attendances if a.meeting_id == m.id],
|
| 59 |
+
preferred_attendances=[a for a in all_preferred_attendances if a.meeting_id == m.id],
|
| 60 |
+
)
|
| 61 |
+
)
|
| 62 |
+
meetings = new_meetings
|
| 63 |
+
|
| 64 |
+
# Meeting assignments
|
| 65 |
+
meeting_assignments = generate_meeting_assignments(meetings)
|
| 66 |
+
|
| 67 |
+
# Create schedule
|
| 68 |
+
schedule = MeetingSchedule(
|
| 69 |
+
people=people,
|
| 70 |
+
time_grains=time_grains,
|
| 71 |
+
rooms=rooms,
|
| 72 |
+
meetings=meetings,
|
| 73 |
+
meeting_assignments=meeting_assignments,
|
| 74 |
+
required_attendances=[ra for meeting in meetings for ra in meeting.required_attendances],
|
| 75 |
+
preferred_attendances=[pa for meeting in meetings for pa in meeting.preferred_attendances],
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
return schedule
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def generate_people(count_people: int, rnd: Random) -> List[Person]:
|
| 82 |
+
"""Generate a list of people."""
|
| 83 |
+
FIRST_NAMES = ["Amy", "Beth", "Carl", "Dan", "Elsa", "Flo", "Gus", "Hugo", "Ivy", "Jay",
|
| 84 |
+
"Jeri", "Hope", "Avis", "Lino", "Lyle", "Nick", "Dino", "Otha", "Gwen", "Jose",
|
| 85 |
+
"Dena", "Jana", "Dave", "Russ", "Josh", "Dana", "Katy"]
|
| 86 |
+
LAST_NAMES = ["Cole", "Fox", "Green", "Jones", "King", "Li", "Poe", "Rye", "Smith", "Watt",
|
| 87 |
+
"Howe", "Lowe", "Wise", "Clay", "Carr", "Hood", "Long", "Horn", "Haas", "Meza"]
|
| 88 |
+
|
| 89 |
+
def generate_name() -> str:
|
| 90 |
+
first_name = rnd.choice(FIRST_NAMES)
|
| 91 |
+
last_name = rnd.choice(LAST_NAMES)
|
| 92 |
+
return f"{first_name} {last_name}"
|
| 93 |
+
|
| 94 |
+
return [Person(id=str(i), full_name=generate_name()) for i in range(count_people)]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def generate_time_grains() -> List[TimeGrain]:
|
| 98 |
+
"""Generate time grains for the next 4 days starting from tomorrow."""
|
| 99 |
+
time_grains = []
|
| 100 |
+
current_date = datetime.now().date() + timedelta(days=1)
|
| 101 |
+
count = 0
|
| 102 |
+
|
| 103 |
+
while current_date < datetime.now().date() + timedelta(days=5): # Match Java: from +1 to +5 (4 days)
|
| 104 |
+
current_time = datetime.combine(current_date, datetime.min.time()) + timedelta(hours=8) # Start at 8:00
|
| 105 |
+
end_time = datetime.combine(current_date, datetime.min.time()) + timedelta(hours=17, minutes=45) # End at 17:45
|
| 106 |
+
|
| 107 |
+
while current_time <= end_time:
|
| 108 |
+
day_of_year = current_date.timetuple().tm_yday
|
| 109 |
+
minutes_of_day = current_time.hour * 60 + current_time.minute
|
| 110 |
+
|
| 111 |
+
count += 1 # Pre-increment like Java ++count
|
| 112 |
+
time_grains.append(TimeGrain(
|
| 113 |
+
id=str(count),
|
| 114 |
+
grain_index=count,
|
| 115 |
+
day_of_year=day_of_year,
|
| 116 |
+
starting_minute_of_day=minutes_of_day
|
| 117 |
+
))
|
| 118 |
+
current_time += timedelta(minutes=15) # 15-minute increments
|
| 119 |
+
|
| 120 |
+
current_date += timedelta(days=1)
|
| 121 |
+
|
| 122 |
+
return time_grains
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def generate_meetings(people: List[Person], rnd: Random) -> List[Meeting]:
|
| 126 |
+
"""Generate meetings with topics and attendees."""
|
| 127 |
+
meeting_topics = [
|
| 128 |
+
"Strategize B2B", "Fast track e-business", "Cross sell virtualization",
|
| 129 |
+
"Profitize multitasking", "Transform one stop shop", "Engage braindumps",
|
| 130 |
+
"Downsize data mining", "Ramp up policies", "On board synergies",
|
| 131 |
+
"Reinvigorate user experience", "Strategize e-business", "Fast track virtualization",
|
| 132 |
+
"Cross sell multitasking", "Profitize one stop shop", "Transform braindumps",
|
| 133 |
+
"Engage data mining", "Downsize policies", "Ramp up synergies",
|
| 134 |
+
"On board user experience", "Reinvigorate B2B", "Strategize virtualization",
|
| 135 |
+
"Fast track multitasking", "Cross sell one stop shop", "Reinvigorate multitasking"
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
+
meetings = []
|
| 139 |
+
for i, topic in enumerate(meeting_topics):
|
| 140 |
+
meeting = Meeting(id=str(i), topic=topic, duration_in_grains=0)
|
| 141 |
+
meetings.append(meeting)
|
| 142 |
+
|
| 143 |
+
# Set durations using CountDistribution and random.choices
|
| 144 |
+
duration_distribution = (
|
| 145 |
+
CountDistribution(count=8, weight=1), # 33% with 8 time grains
|
| 146 |
+
CountDistribution(count=12, weight=1), # 33% with 12 time grains
|
| 147 |
+
CountDistribution(count=16, weight=1) # 33% with 16 time grains
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
for meeting in meetings:
|
| 151 |
+
duration_time_grains, = rnd.choices(population=counts(duration_distribution),
|
| 152 |
+
weights=weights(duration_distribution))
|
| 153 |
+
meeting.duration_in_grains = duration_time_grains
|
| 154 |
+
|
| 155 |
+
# Add required attendees using CountDistribution - slightly reduced to make more feasible
|
| 156 |
+
required_attendees_distribution = (
|
| 157 |
+
CountDistribution(count=2, weight=0.45), # More 2-person meetings
|
| 158 |
+
CountDistribution(count=3, weight=0.15), # More 3-person meetings
|
| 159 |
+
CountDistribution(count=4, weight=0.10), # Increased 4-person
|
| 160 |
+
CountDistribution(count=5, weight=0.10), # Slightly more 5-person
|
| 161 |
+
CountDistribution(count=6, weight=0.08), # Reduced larger meetings
|
| 162 |
+
CountDistribution(count=7, weight=0.05),
|
| 163 |
+
CountDistribution(count=8, weight=0.04),
|
| 164 |
+
CountDistribution(count=10, weight=0.03) # Reduced 10-person meetings
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
def add_required_attendees(meeting: Meeting, count: int) -> None:
|
| 168 |
+
# Use random.sample to avoid duplicates
|
| 169 |
+
selected_people = rnd.sample(people, count)
|
| 170 |
+
for person in selected_people:
|
| 171 |
+
meeting.required_attendances.append(
|
| 172 |
+
RequiredAttendance(
|
| 173 |
+
id=f"{meeting.id}-{len(meeting.required_attendances) + 1}",
|
| 174 |
+
person=person,
|
| 175 |
+
meeting_id=meeting.id
|
| 176 |
+
)
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
for meeting in meetings:
|
| 180 |
+
count, = rnd.choices(population=counts(required_attendees_distribution),
|
| 181 |
+
weights=weights(required_attendees_distribution))
|
| 182 |
+
add_required_attendees(meeting, count)
|
| 183 |
+
|
| 184 |
+
# Add preferred attendees using CountDistribution - reduced to make more feasible
|
| 185 |
+
preferred_attendees_distribution = (
|
| 186 |
+
CountDistribution(count=1, weight=0.25), # More 1-person preferred
|
| 187 |
+
CountDistribution(count=2, weight=0.30), # More 2-person preferred
|
| 188 |
+
CountDistribution(count=3, weight=0.20), # More 3-person preferred
|
| 189 |
+
CountDistribution(count=4, weight=0.10), # Increased 4-person
|
| 190 |
+
CountDistribution(count=5, weight=0.06), # Slightly more 5-person
|
| 191 |
+
CountDistribution(count=6, weight=0.04), # Reduced larger groups
|
| 192 |
+
CountDistribution(count=7, weight=0.02), # Reduced
|
| 193 |
+
CountDistribution(count=8, weight=0.02), # Reduced
|
| 194 |
+
CountDistribution(count=9, weight=0.01), # Minimal large groups
|
| 195 |
+
CountDistribution(count=10, weight=0.00) # Eliminated 10-person preferred
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
def add_preferred_attendees(meeting: Meeting, count: int) -> None:
|
| 199 |
+
# Get people not already required for this meeting
|
| 200 |
+
required_people_ids = {ra.person.id for ra in meeting.required_attendances}
|
| 201 |
+
available_people = [person for person in people if person.id not in required_people_ids]
|
| 202 |
+
|
| 203 |
+
# Use random.sample to avoid duplicates, but only if we have enough people
|
| 204 |
+
if len(available_people) >= count:
|
| 205 |
+
selected_people = rnd.sample(available_people, count)
|
| 206 |
+
for person in selected_people:
|
| 207 |
+
meeting.preferred_attendances.append(
|
| 208 |
+
PreferredAttendance(
|
| 209 |
+
id=f"{meeting.id}-{len(meeting.required_attendances) + len(meeting.preferred_attendances) + 1}",
|
| 210 |
+
person=person,
|
| 211 |
+
meeting_id=meeting.id
|
| 212 |
+
)
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
for meeting in meetings:
|
| 216 |
+
count, = rnd.choices(population=counts(preferred_attendees_distribution),
|
| 217 |
+
weights=weights(preferred_attendees_distribution))
|
| 218 |
+
add_preferred_attendees(meeting, count)
|
| 219 |
+
|
| 220 |
+
return meetings
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def generate_meeting_assignments(meetings: List[Meeting]) -> List[MeetingAssignment]:
|
| 224 |
+
"""Generate meeting assignments for each meeting."""
|
| 225 |
+
return [MeetingAssignment(id=str(i), meeting=meeting) for i, meeting in enumerate(meetings)]
|
src/meeting_scheduling/domain.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from dataclasses import dataclass, field
|
| 2 |
+
from typing import List, Optional, Annotated, Union
|
| 3 |
+
from solverforge_legacy.solver.domain import (
|
| 4 |
+
planning_entity,
|
| 5 |
+
planning_solution,
|
| 6 |
+
PlanningId,
|
| 7 |
+
PlanningVariable,
|
| 8 |
+
PlanningEntityCollectionProperty,
|
| 9 |
+
ProblemFactCollectionProperty,
|
| 10 |
+
ValueRangeProvider,
|
| 11 |
+
PlanningScore,
|
| 12 |
+
PlanningPin,
|
| 13 |
+
)
|
| 14 |
+
from solverforge_legacy.solver import SolverStatus
|
| 15 |
+
from solverforge_legacy.solver.score import HardMediumSoftScore
|
| 16 |
+
from .json_serialization import JsonDomainBase
|
| 17 |
+
from pydantic import Field
|
| 18 |
+
|
| 19 |
+
# Time granularity is 15 minutes (which is often recommended when dealing with humans for practical purposes).
|
| 20 |
+
GRAIN_LENGTH_IN_MINUTES = 15
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class Person:
|
| 25 |
+
id: Annotated[str, PlanningId]
|
| 26 |
+
full_name: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class TimeGrain:
|
| 31 |
+
id: Annotated[str, PlanningId]
|
| 32 |
+
grain_index: int
|
| 33 |
+
day_of_year: int
|
| 34 |
+
starting_minute_of_day: int
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class Room:
|
| 39 |
+
id: Annotated[str, PlanningId]
|
| 40 |
+
name: str
|
| 41 |
+
capacity: int
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# Define Attendance base class and subclasses before Meeting to avoid forward references
|
| 45 |
+
@dataclass
|
| 46 |
+
class Attendance:
|
| 47 |
+
id: Annotated[str, PlanningId]
|
| 48 |
+
person: Person
|
| 49 |
+
meeting_id: str
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass
|
| 53 |
+
class RequiredAttendance:
|
| 54 |
+
id: Annotated[str, PlanningId]
|
| 55 |
+
person: Person
|
| 56 |
+
meeting_id: str
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class PreferredAttendance:
|
| 61 |
+
id: Annotated[str, PlanningId]
|
| 62 |
+
person: Person
|
| 63 |
+
meeting_id: str
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass
|
| 67 |
+
class Meeting:
|
| 68 |
+
id: Annotated[str, PlanningId]
|
| 69 |
+
topic: str
|
| 70 |
+
duration_in_grains: int
|
| 71 |
+
speakers: Optional[List[Person]] = None
|
| 72 |
+
content: Optional[str] = None
|
| 73 |
+
entire_group_meeting: bool = False
|
| 74 |
+
required_attendances: List[RequiredAttendance] = field(default_factory=list)
|
| 75 |
+
preferred_attendances: List[PreferredAttendance] = field(default_factory=list)
|
| 76 |
+
|
| 77 |
+
def get_required_capacity(self) -> int:
|
| 78 |
+
return len(self.required_attendances) + len(self.preferred_attendances)
|
| 79 |
+
|
| 80 |
+
def add_required_attendant(self, person: Person) -> None:
|
| 81 |
+
person_id = person.id
|
| 82 |
+
for r in self.required_attendances:
|
| 83 |
+
if r.person.id == person_id:
|
| 84 |
+
raise ValueError(
|
| 85 |
+
f"The person {person_id} is already assigned to the meeting {self.id}."
|
| 86 |
+
)
|
| 87 |
+
self.required_attendances.append(
|
| 88 |
+
RequiredAttendance(
|
| 89 |
+
id=f"{self.id}-{self.get_required_capacity() + 1}",
|
| 90 |
+
meeting_id=self.id,
|
| 91 |
+
person=person,
|
| 92 |
+
)
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
def add_preferred_attendant(self, person: Person) -> None:
|
| 96 |
+
person_id = person.id
|
| 97 |
+
for p in self.preferred_attendances:
|
| 98 |
+
if p.person.id == person_id:
|
| 99 |
+
raise ValueError(
|
| 100 |
+
f"The person {person_id} is already assigned to the meeting {self.id}."
|
| 101 |
+
)
|
| 102 |
+
self.preferred_attendances.append(
|
| 103 |
+
PreferredAttendance(
|
| 104 |
+
id=f"{self.id}-{self.get_required_capacity() + 1}",
|
| 105 |
+
meeting_id=self.id,
|
| 106 |
+
person=person,
|
| 107 |
+
)
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@planning_entity
|
| 112 |
+
@dataclass
|
| 113 |
+
class MeetingAssignment:
|
| 114 |
+
id: Annotated[str, PlanningId]
|
| 115 |
+
meeting: Meeting
|
| 116 |
+
pinned: Annotated[bool, PlanningPin] = False
|
| 117 |
+
starting_time_grain: Annotated[Optional[TimeGrain], PlanningVariable] = None
|
| 118 |
+
room: Annotated[Optional[Room], PlanningVariable] = None
|
| 119 |
+
|
| 120 |
+
def get_grain_index(self) -> Optional[int]:
|
| 121 |
+
if self.starting_time_grain is None:
|
| 122 |
+
return None
|
| 123 |
+
return self.starting_time_grain.grain_index
|
| 124 |
+
|
| 125 |
+
def calculate_overlap(self, other: "MeetingAssignment") -> int:
|
| 126 |
+
if self.starting_time_grain is None or other.starting_time_grain is None:
|
| 127 |
+
return 0
|
| 128 |
+
# start is inclusive, end is exclusive
|
| 129 |
+
start = self.starting_time_grain.grain_index
|
| 130 |
+
end = self.get_last_time_grain_index() + 1
|
| 131 |
+
other_start = other.starting_time_grain.grain_index
|
| 132 |
+
other_end = other.get_last_time_grain_index() + 1
|
| 133 |
+
|
| 134 |
+
if other_end < start or end < other_start:
|
| 135 |
+
return 0
|
| 136 |
+
|
| 137 |
+
return min(end, other_end) - max(start, other_start)
|
| 138 |
+
|
| 139 |
+
def get_last_time_grain_index(self) -> Optional[int]:
|
| 140 |
+
if self.starting_time_grain is None:
|
| 141 |
+
return None
|
| 142 |
+
return (
|
| 143 |
+
self.starting_time_grain.grain_index + self.meeting.duration_in_grains - 1
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
def get_room_capacity(self) -> int:
|
| 147 |
+
if self.room is None:
|
| 148 |
+
return 0
|
| 149 |
+
return self.room.capacity
|
| 150 |
+
|
| 151 |
+
def get_required_capacity(self) -> int:
|
| 152 |
+
return self.meeting.get_required_capacity()
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@planning_solution
|
| 156 |
+
@dataclass
|
| 157 |
+
class MeetingSchedule:
|
| 158 |
+
people: Annotated[List[Person], ProblemFactCollectionProperty]
|
| 159 |
+
time_grains: Annotated[
|
| 160 |
+
List[TimeGrain], ProblemFactCollectionProperty, ValueRangeProvider
|
| 161 |
+
]
|
| 162 |
+
rooms: Annotated[List[Room], ProblemFactCollectionProperty, ValueRangeProvider]
|
| 163 |
+
meetings: Annotated[List[Meeting], ProblemFactCollectionProperty]
|
| 164 |
+
required_attendances: Annotated[
|
| 165 |
+
List[RequiredAttendance], ProblemFactCollectionProperty
|
| 166 |
+
] = field(default_factory=list)
|
| 167 |
+
preferred_attendances: Annotated[
|
| 168 |
+
List[PreferredAttendance], ProblemFactCollectionProperty
|
| 169 |
+
] = field(default_factory=list)
|
| 170 |
+
attendances: Annotated[
|
| 171 |
+
List[Attendance], ProblemFactCollectionProperty
|
| 172 |
+
] = field(default_factory=list)
|
| 173 |
+
meeting_assignments: Annotated[
|
| 174 |
+
List[MeetingAssignment], PlanningEntityCollectionProperty
|
| 175 |
+
] = field(default_factory=list)
|
| 176 |
+
score: Annotated[Optional[HardMediumSoftScore], PlanningScore] = None
|
| 177 |
+
solver_status: SolverStatus = SolverStatus.NOT_SOLVING
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
# Pydantic REST models for API (used for deserialization and context)
|
| 181 |
+
class PersonModel(JsonDomainBase):
|
| 182 |
+
id: str
|
| 183 |
+
full_name: str
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class TimeGrainModel(JsonDomainBase):
|
| 187 |
+
id: str
|
| 188 |
+
grain_index: int
|
| 189 |
+
day_of_year: int
|
| 190 |
+
starting_minute_of_day: int
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
class RoomModel(JsonDomainBase):
|
| 194 |
+
id: str
|
| 195 |
+
name: str
|
| 196 |
+
capacity: int
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class RequiredAttendanceModel(JsonDomainBase):
|
| 200 |
+
id: str
|
| 201 |
+
person: PersonModel
|
| 202 |
+
meeting_id: str = Field(..., alias="meeting")
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
class PreferredAttendanceModel(JsonDomainBase):
|
| 206 |
+
id: str
|
| 207 |
+
person: PersonModel
|
| 208 |
+
meeting_id: str = Field(..., alias="meeting")
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
class MeetingModel(JsonDomainBase):
|
| 212 |
+
id: str
|
| 213 |
+
topic: str
|
| 214 |
+
duration_in_grains: int
|
| 215 |
+
speakers: Optional[List[PersonModel]] = None
|
| 216 |
+
content: Optional[str] = None
|
| 217 |
+
entire_group_meeting: bool = False
|
| 218 |
+
required_attendances: List[RequiredAttendanceModel] = Field(
|
| 219 |
+
default_factory=list, alias="requiredAttendances"
|
| 220 |
+
)
|
| 221 |
+
preferred_attendances: List[PreferredAttendanceModel] = Field(
|
| 222 |
+
default_factory=list, alias="preferredAttendances"
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class MeetingAssignmentModel(JsonDomainBase):
|
| 227 |
+
id: str
|
| 228 |
+
meeting: Union[str, MeetingModel]
|
| 229 |
+
pinned: bool = False
|
| 230 |
+
starting_time_grain: Union[str, TimeGrainModel, None] = None
|
| 231 |
+
room: Union[str, RoomModel, None] = None
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
class MeetingScheduleModel(JsonDomainBase):
|
| 235 |
+
people: List[PersonModel]
|
| 236 |
+
time_grains: List[TimeGrainModel]
|
| 237 |
+
rooms: List[RoomModel]
|
| 238 |
+
meetings: List[MeetingModel]
|
| 239 |
+
required_attendances: List[RequiredAttendanceModel] = Field(
|
| 240 |
+
default_factory=list, alias="requiredAttendances"
|
| 241 |
+
)
|
| 242 |
+
preferred_attendances: List[PreferredAttendanceModel] = Field(
|
| 243 |
+
default_factory=list, alias="preferredAttendances"
|
| 244 |
+
)
|
| 245 |
+
meeting_assignments: List[MeetingAssignmentModel] = Field(default_factory=list)
|
| 246 |
+
score: Optional[str] = None
|
| 247 |
+
solver_status: Optional[str] = None
|
src/meeting_scheduling/json_serialization.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from solverforge_legacy.solver.score import HardMediumSoftScore
|
| 2 |
+
from pydantic import (
|
| 3 |
+
BaseModel,
|
| 4 |
+
ConfigDict,
|
| 5 |
+
PlainSerializer,
|
| 6 |
+
BeforeValidator,
|
| 7 |
+
ValidationInfo,
|
| 8 |
+
)
|
| 9 |
+
from pydantic.alias_generators import to_camel
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def make_list_item_validator(key: str):
|
| 14 |
+
def validator(v: Any, info: ValidationInfo) -> Any:
|
| 15 |
+
if v is None:
|
| 16 |
+
return None
|
| 17 |
+
|
| 18 |
+
if not isinstance(v, str) or not info.context:
|
| 19 |
+
return v
|
| 20 |
+
|
| 21 |
+
return info.context.get(key, {}).get(v, v)
|
| 22 |
+
|
| 23 |
+
return BeforeValidator(validator)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Validators for foreign key references
|
| 27 |
+
MeetingDeserializer = make_list_item_validator("meetings")
|
| 28 |
+
RoomDeserializer = make_list_item_validator("rooms")
|
| 29 |
+
TimeGrainDeserializer = make_list_item_validator("timeGrains")
|
| 30 |
+
|
| 31 |
+
IdSerializer = PlainSerializer(
|
| 32 |
+
lambda item: item.id if item is not None else None, return_type=str | None
|
| 33 |
+
)
|
| 34 |
+
ScoreSerializer = PlainSerializer(
|
| 35 |
+
lambda score: str(score) if score is not None else None, return_type=str | None
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def validate_score(v: Any, info: ValidationInfo) -> Any:
|
| 40 |
+
if isinstance(v, HardMediumSoftScore) or v is None:
|
| 41 |
+
return v
|
| 42 |
+
if isinstance(v, str):
|
| 43 |
+
return HardMediumSoftScore.parse(v)
|
| 44 |
+
raise ValueError('"score" should be a string')
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
ScoreValidator = BeforeValidator(validate_score)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class JsonDomainBase(BaseModel):
|
| 51 |
+
model_config = ConfigDict(
|
| 52 |
+
alias_generator=to_camel,
|
| 53 |
+
populate_by_name=True,
|
| 54 |
+
from_attributes=True,
|
| 55 |
+
)
|
src/meeting_scheduling/rest_api.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, Request
|
| 2 |
+
from fastapi.staticfiles import StaticFiles
|
| 3 |
+
from typing import Dict, List
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
|
| 6 |
+
from .domain import MeetingSchedule
|
| 7 |
+
from .converters import MeetingScheduleModel, schedule_to_model, model_to_schedule
|
| 8 |
+
from .demo_data import generate_demo_data
|
| 9 |
+
from .solver import solver_manager, solution_manager
|
| 10 |
+
from .score_analysis import ConstraintAnalysisDTO, MatchAnalysisDTO
|
| 11 |
+
|
| 12 |
+
app = FastAPI(docs_url="/q/swagger-ui")
|
| 13 |
+
|
| 14 |
+
# Dictionary to store submitted data sets (using domain models internally)
|
| 15 |
+
data_sets: Dict[str, MeetingSchedule] = {}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@app.get("/demo-data")
|
| 19 |
+
async def list_demo_data() -> List[str]:
|
| 20 |
+
"""List available demo data sets."""
|
| 21 |
+
return ["SMALL", "MEDIUM", "LARGE"]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@app.get("/demo-data/{dataset_id}")
|
| 25 |
+
async def get_demo_data(dataset_id: str) -> MeetingScheduleModel:
|
| 26 |
+
"""Get a demo data set by ID."""
|
| 27 |
+
domain_schedule = generate_demo_data()
|
| 28 |
+
return schedule_to_model(domain_schedule)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@app.get("/schedules")
|
| 32 |
+
async def list_schedules() -> List[str]:
|
| 33 |
+
"""List all job IDs of submitted schedules."""
|
| 34 |
+
return list(data_sets.keys())
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@app.get("/schedules/{schedule_id}")
|
| 38 |
+
async def get_schedule(schedule_id: str) -> MeetingScheduleModel:
|
| 39 |
+
"""Get the solution and score for a given job ID."""
|
| 40 |
+
if schedule_id not in data_sets:
|
| 41 |
+
raise ValueError(f"No schedule found with ID {schedule_id}")
|
| 42 |
+
|
| 43 |
+
schedule = data_sets[schedule_id]
|
| 44 |
+
solver_status = solver_manager.get_solver_status(schedule_id)
|
| 45 |
+
schedule.solver_status = solver_status
|
| 46 |
+
|
| 47 |
+
return schedule_to_model(schedule)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@app.get("/schedules/{problem_id}/status")
|
| 51 |
+
async def get_status(problem_id: str) -> Dict:
|
| 52 |
+
"""Get the schedule status and score for a given job ID."""
|
| 53 |
+
if problem_id not in data_sets:
|
| 54 |
+
raise ValueError(f"No schedule found with ID {problem_id}")
|
| 55 |
+
|
| 56 |
+
schedule = data_sets[problem_id]
|
| 57 |
+
solver_status = solver_manager.get_solver_status(problem_id)
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
"score": {
|
| 61 |
+
"hardScore": schedule.score.hard_score if schedule.score else 0,
|
| 62 |
+
"mediumScore": schedule.score.medium_score if schedule.score else 0,
|
| 63 |
+
"softScore": schedule.score.soft_score if schedule.score else 0,
|
| 64 |
+
},
|
| 65 |
+
"solverStatus": solver_status.name,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@app.delete("/schedules/{problem_id}")
|
| 70 |
+
async def terminate_solving(problem_id: str) -> MeetingScheduleModel:
|
| 71 |
+
"""Terminate solving for a given job ID."""
|
| 72 |
+
if problem_id not in data_sets:
|
| 73 |
+
raise ValueError(f"No schedule found with ID {problem_id}")
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
solver_manager.terminate_early(problem_id)
|
| 77 |
+
except Exception as e:
|
| 78 |
+
print(f"Warning: terminate_early failed for {problem_id}: {e}")
|
| 79 |
+
|
| 80 |
+
return await get_schedule(problem_id)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def update_schedule(problem_id: str, schedule: MeetingSchedule) -> None:
|
| 84 |
+
"""Update the schedule in the data sets."""
|
| 85 |
+
global data_sets
|
| 86 |
+
data_sets[problem_id] = schedule
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@app.post("/schedules")
|
| 90 |
+
async def solve_schedule(request: Request) -> str:
|
| 91 |
+
json_data = await request.json()
|
| 92 |
+
job_id = str(uuid4())
|
| 93 |
+
|
| 94 |
+
# Parse the incoming JSON using Pydantic models
|
| 95 |
+
schedule_model = MeetingScheduleModel.model_validate(json_data)
|
| 96 |
+
|
| 97 |
+
# Convert to domain model for solver
|
| 98 |
+
domain_schedule = model_to_schedule(schedule_model)
|
| 99 |
+
|
| 100 |
+
data_sets[job_id] = domain_schedule
|
| 101 |
+
solver_manager.solve_and_listen(
|
| 102 |
+
job_id, domain_schedule, lambda solution: update_schedule(job_id, solution)
|
| 103 |
+
)
|
| 104 |
+
return job_id
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@app.put("/schedules/analyze")
|
| 108 |
+
async def analyze_schedule(request: Request) -> Dict:
|
| 109 |
+
"""Submit a schedule to analyze its score."""
|
| 110 |
+
json_data = await request.json()
|
| 111 |
+
|
| 112 |
+
# Parse the incoming JSON using Pydantic models
|
| 113 |
+
schedule_model = MeetingScheduleModel.model_validate(json_data)
|
| 114 |
+
|
| 115 |
+
# Convert to domain model for analysis
|
| 116 |
+
domain_schedule = model_to_schedule(schedule_model)
|
| 117 |
+
|
| 118 |
+
analysis = solution_manager.analyze(domain_schedule)
|
| 119 |
+
|
| 120 |
+
def serialize_justification(justification):
|
| 121 |
+
"""Convert justification facts to serializable dicts."""
|
| 122 |
+
if justification is None:
|
| 123 |
+
return None
|
| 124 |
+
facts = []
|
| 125 |
+
for fact in getattr(justification, 'facts', []):
|
| 126 |
+
fact_dict = {'id': getattr(fact, 'id', None)}
|
| 127 |
+
# MeetingAssignment - has meeting attribute
|
| 128 |
+
if hasattr(fact, 'meeting'):
|
| 129 |
+
fact_dict['type'] = 'assignment'
|
| 130 |
+
fact_dict['meeting'] = getattr(fact.meeting, 'id', None) if fact.meeting else None
|
| 131 |
+
# RequiredAttendance/PreferredAttendance - has person and meeting_id
|
| 132 |
+
elif hasattr(fact, 'person') and hasattr(fact, 'meeting_id'):
|
| 133 |
+
fact_dict['type'] = 'attendance'
|
| 134 |
+
fact_dict['personId'] = getattr(fact.person, 'id', None) if fact.person else None
|
| 135 |
+
fact_dict['meetingId'] = fact.meeting_id
|
| 136 |
+
facts.append(fact_dict)
|
| 137 |
+
return {'facts': facts}
|
| 138 |
+
|
| 139 |
+
# Convert to proper DTOs for correct serialization
|
| 140 |
+
constraints = []
|
| 141 |
+
for constraint in analysis.constraint_analyses:
|
| 142 |
+
matches = [
|
| 143 |
+
MatchAnalysisDTO(
|
| 144 |
+
name=match.constraint_ref.constraint_name,
|
| 145 |
+
score=match.score,
|
| 146 |
+
justification=serialize_justification(match.justification),
|
| 147 |
+
)
|
| 148 |
+
for match in constraint.matches
|
| 149 |
+
]
|
| 150 |
+
|
| 151 |
+
constraint_dto = ConstraintAnalysisDTO(
|
| 152 |
+
name=constraint.constraint_name,
|
| 153 |
+
weight=constraint.weight,
|
| 154 |
+
score=constraint.score,
|
| 155 |
+
matches=matches,
|
| 156 |
+
)
|
| 157 |
+
constraints.append(constraint_dto)
|
| 158 |
+
|
| 159 |
+
return {"constraints": [constraint.model_dump() for constraint in constraints]}
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# Mount static files
|
| 163 |
+
app.mount("/", StaticFiles(directory="static", html=True), name="static")
|
src/meeting_scheduling/score_analysis.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
from typing import List, Any, Annotated
|
| 3 |
+
from solverforge_legacy.solver.score import HardMediumSoftScore
|
| 4 |
+
from .json_serialization import ScoreSerializer
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class MatchAnalysisDTO(BaseModel):
|
| 8 |
+
name: str
|
| 9 |
+
score: Annotated[HardMediumSoftScore, ScoreSerializer]
|
| 10 |
+
justification: Any
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ConstraintAnalysisDTO(BaseModel):
|
| 14 |
+
name: str
|
| 15 |
+
weight: Annotated[HardMediumSoftScore, ScoreSerializer]
|
| 16 |
+
score: Annotated[HardMediumSoftScore, ScoreSerializer]
|
| 17 |
+
matches: List[MatchAnalysisDTO]
|
src/meeting_scheduling/solver.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 MeetingSchedule, MeetingAssignment
|
| 10 |
+
from .constraints import define_constraints
|
| 11 |
+
|
| 12 |
+
solver_config = SolverConfig(
|
| 13 |
+
solution_class=MeetingSchedule,
|
| 14 |
+
entity_class_list=[MeetingAssignment],
|
| 15 |
+
score_director_factory_config=ScoreDirectorFactoryConfig(
|
| 16 |
+
constraint_provider_function=define_constraints
|
| 17 |
+
),
|
| 18 |
+
termination_config=TerminationConfig(spent_limit=Duration(seconds=30)),
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
solver_manager = SolverManager.create(SolverFactory.create(solver_config))
|
| 22 |
+
solution_manager = SolutionManager.create(solver_manager)
|
static/app.js
ADDED
|
@@ -0,0 +1,1023 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
let autoRefreshIntervalId = null;
|
| 2 |
+
const formatter = JSJoda.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
| 3 |
+
const startTime = formatter.format(JSJoda.LocalDateTime.now().withHour(20).withMinute(0).withSecond(0));
|
| 4 |
+
const endTime = formatter.format(JSJoda.LocalDateTime.now().plusDays(1).withHour(8).withMinute(0).withSecond(0));
|
| 5 |
+
const zoomMin = 1000 * 60 * 60 // one hour in milliseconds
|
| 6 |
+
const zoomMax = 4 * 1000 * 60 * 60 * 24 // 5 days in milliseconds
|
| 7 |
+
|
| 8 |
+
const byTimelineOptions = {
|
| 9 |
+
timeAxis: {scale: "hour", step: 1},
|
| 10 |
+
orientation: {axis: "top"},
|
| 11 |
+
stack: false,
|
| 12 |
+
xss: {disabled: true}, // Items are XSS safe through JQuery
|
| 13 |
+
zoomMin: zoomMin,
|
| 14 |
+
zoomMax: zoomMax,
|
| 15 |
+
showCurrentTime: false,
|
| 16 |
+
hiddenDates: [
|
| 17 |
+
{
|
| 18 |
+
start: startTime,
|
| 19 |
+
end: endTime,
|
| 20 |
+
repeat: 'daily'
|
| 21 |
+
}
|
| 22 |
+
],
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
const byRoomPanel = document.getElementById("byRoomPanel");
|
| 26 |
+
let byRoomGroupData = new vis.DataSet();
|
| 27 |
+
let byRoomItemData = new vis.DataSet();
|
| 28 |
+
let byRoomTimeline = new vis.Timeline(byRoomPanel, byRoomItemData, byRoomGroupData, byTimelineOptions);
|
| 29 |
+
|
| 30 |
+
const byPersonPanel = document.getElementById("byPersonPanel");
|
| 31 |
+
let byPersonGroupData = new vis.DataSet();
|
| 32 |
+
let byPersonItemData = new vis.DataSet();
|
| 33 |
+
let byPersonTimeline = new vis.Timeline(byPersonPanel, byPersonItemData, byPersonGroupData, byTimelineOptions);
|
| 34 |
+
|
| 35 |
+
let scheduleId = null;
|
| 36 |
+
let loadedSchedule = null;
|
| 37 |
+
let viewType = "R";
|
| 38 |
+
let selectedDemoData = "MEDIUM"; // Default demo data size
|
| 39 |
+
let analyzeCache = null; // Cache for solver's constraint analysis (assignmentId -> violations)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
let appInitialized = false;
|
| 43 |
+
|
| 44 |
+
$(document).ready(function () {
|
| 45 |
+
// Ensure all resources are loaded before initializing
|
| 46 |
+
$(window).on('load', function() {
|
| 47 |
+
if (!appInitialized) {
|
| 48 |
+
appInitialized = true;
|
| 49 |
+
initializeApp();
|
| 50 |
+
}
|
| 51 |
+
});
|
| 52 |
+
|
| 53 |
+
// Fallback if window load event doesn't fire
|
| 54 |
+
setTimeout(function() {
|
| 55 |
+
if (!appInitialized) {
|
| 56 |
+
appInitialized = true;
|
| 57 |
+
initializeApp();
|
| 58 |
+
}
|
| 59 |
+
}, 100);
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
function initializeApp() {
|
| 63 |
+
replaceQuickstartSolverForgeAutoHeaderFooter();
|
| 64 |
+
|
| 65 |
+
$("#solveButton").click(function () {
|
| 66 |
+
solve();
|
| 67 |
+
});
|
| 68 |
+
$("#stopSolvingButton").click(function () {
|
| 69 |
+
stopSolving();
|
| 70 |
+
});
|
| 71 |
+
$("#analyzeButton").click(function () {
|
| 72 |
+
analyze();
|
| 73 |
+
});
|
| 74 |
+
$("#byRoomTab").click(function () {
|
| 75 |
+
viewType = "R";
|
| 76 |
+
byRoomTimeline.redraw();
|
| 77 |
+
refreshSchedule();
|
| 78 |
+
});
|
| 79 |
+
$("#byPersonTab").click(function () {
|
| 80 |
+
viewType = "P";
|
| 81 |
+
byPersonTimeline.redraw();
|
| 82 |
+
refreshSchedule();
|
| 83 |
+
});
|
| 84 |
+
setupAjax();
|
| 85 |
+
loadDemoDataDropdown();
|
| 86 |
+
refreshSchedule();
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
function loadDemoDataDropdown() {
|
| 91 |
+
$.getJSON("/demo-data", function (demoDataList) {
|
| 92 |
+
const dropdown = $("#testDataButton");
|
| 93 |
+
dropdown.empty();
|
| 94 |
+
|
| 95 |
+
demoDataList.forEach(function (name) {
|
| 96 |
+
const isSelected = name === selectedDemoData;
|
| 97 |
+
const item = $(`<a class="dropdown-item" href="#"></a>`)
|
| 98 |
+
.text(name)
|
| 99 |
+
.css("font-weight", isSelected ? "bold" : "normal")
|
| 100 |
+
.click(function (e) {
|
| 101 |
+
e.preventDefault();
|
| 102 |
+
selectDemoData(name);
|
| 103 |
+
});
|
| 104 |
+
dropdown.append(item);
|
| 105 |
+
});
|
| 106 |
+
});
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
function selectDemoData(name) {
|
| 111 |
+
selectedDemoData = name;
|
| 112 |
+
scheduleId = null; // Reset solver job
|
| 113 |
+
loadDemoDataDropdown(); // Refresh dropdown to show selection
|
| 114 |
+
refreshSchedule();
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
function setupAjax() {
|
| 119 |
+
$.ajaxSetup({
|
| 120 |
+
headers: {
|
| 121 |
+
'Content-Type': 'application/json', 'Accept': 'application/json,text/plain', // plain text is required by solve() returning UUID of the solver job
|
| 122 |
+
}
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
// Extend jQuery to support $.put() and $.delete()
|
| 126 |
+
jQuery.each(["put", "delete"], function (i, method) {
|
| 127 |
+
jQuery[method] = function (url, data, callback, type) {
|
| 128 |
+
if (jQuery.isFunction(data)) {
|
| 129 |
+
type = type || callback;
|
| 130 |
+
callback = data;
|
| 131 |
+
data = undefined;
|
| 132 |
+
}
|
| 133 |
+
return jQuery.ajax({
|
| 134 |
+
url: url, type: method, dataType: type, data: data, success: callback
|
| 135 |
+
});
|
| 136 |
+
};
|
| 137 |
+
});
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
function refreshSchedule() {
|
| 142 |
+
let path;
|
| 143 |
+
if (scheduleId === null) {
|
| 144 |
+
path = "/demo-data/" + selectedDemoData;
|
| 145 |
+
} else {
|
| 146 |
+
path = "/schedules/" + scheduleId;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
$.getJSON(path, function (schedule) {
|
| 150 |
+
loadedSchedule = schedule;
|
| 151 |
+
$('#exportData').attr('href', 'data:text/plain;charset=utf-8,' + JSON.stringify(loadedSchedule));
|
| 152 |
+
renderSchedule(schedule);
|
| 153 |
+
})
|
| 154 |
+
.fail(function (xhr, ajaxOptions, thrownError) {
|
| 155 |
+
showError("Getting the schedule has failed.", xhr);
|
| 156 |
+
refreshSolvingButtons(false);
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
function renderSchedule(schedule) {
|
| 162 |
+
refreshSolvingButtons(schedule.solverStatus != null && schedule.solverStatus !== "NOT_SOLVING");
|
| 163 |
+
$("#score").text("Score: " + (schedule.score == null ? "?" : schedule.score));
|
| 164 |
+
|
| 165 |
+
// Fetch constraint analysis from solver if we have a score
|
| 166 |
+
if (schedule.score) {
|
| 167 |
+
fetchConstraintAnalysis(schedule);
|
| 168 |
+
} else {
|
| 169 |
+
// No score yet - clear cache and render with no violations
|
| 170 |
+
analyzeCache = null;
|
| 171 |
+
renderViews(schedule);
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
function renderViews(schedule) {
|
| 177 |
+
if (viewType === "R") {
|
| 178 |
+
renderScheduleByRoom(schedule);
|
| 179 |
+
}
|
| 180 |
+
if (viewType === "P") {
|
| 181 |
+
renderScheduleByPerson(schedule);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
function fetchConstraintAnalysis(schedule) {
|
| 187 |
+
$.ajax({
|
| 188 |
+
url: "/schedules/analyze",
|
| 189 |
+
type: "PUT",
|
| 190 |
+
data: JSON.stringify(schedule),
|
| 191 |
+
contentType: "application/json",
|
| 192 |
+
success: function(response) {
|
| 193 |
+
// Build mapping: assignmentId -> { hard: [], medium: [], soft: [] }
|
| 194 |
+
analyzeCache = new Map();
|
| 195 |
+
|
| 196 |
+
for (const constraint of response.constraints) {
|
| 197 |
+
const type = getConstraintType(constraint.weight);
|
| 198 |
+
|
| 199 |
+
for (const match of constraint.matches) {
|
| 200 |
+
const assignmentIds = extractAssignmentIds(match.justification, loadedSchedule);
|
| 201 |
+
|
| 202 |
+
for (const id of assignmentIds) {
|
| 203 |
+
if (!analyzeCache.has(id)) {
|
| 204 |
+
analyzeCache.set(id, { hard: [], medium: [], soft: [] });
|
| 205 |
+
}
|
| 206 |
+
analyzeCache.get(id)[type].push({
|
| 207 |
+
constraint: constraint.name,
|
| 208 |
+
score: match.score
|
| 209 |
+
});
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
// Re-render with new analysis data
|
| 215 |
+
renderViews(loadedSchedule);
|
| 216 |
+
},
|
| 217 |
+
error: function(xhr, status, error) {
|
| 218 |
+
console.warn("Failed to fetch constraint analysis:", error);
|
| 219 |
+
analyzeCache = null;
|
| 220 |
+
renderViews(schedule);
|
| 221 |
+
}
|
| 222 |
+
});
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
function getConstraintType(weight) {
|
| 227 |
+
// Weight format: "0hard/0medium/-1soft" or "1hard/0medium/0soft"
|
| 228 |
+
// Extract the non-zero component
|
| 229 |
+
const hardMatch = weight.match(/(-?\d+)hard/);
|
| 230 |
+
const mediumMatch = weight.match(/(-?\d+)medium/);
|
| 231 |
+
const softMatch = weight.match(/(-?\d+)soft/);
|
| 232 |
+
|
| 233 |
+
if (hardMatch && parseInt(hardMatch[1], 10) !== 0) return 'hard';
|
| 234 |
+
if (mediumMatch && parseInt(mediumMatch[1], 10) !== 0) return 'medium';
|
| 235 |
+
if (softMatch && parseInt(softMatch[1], 10) !== 0) return 'soft';
|
| 236 |
+
|
| 237 |
+
return 'soft'; // Default
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
function extractAssignmentIds(justification, schedule) {
|
| 242 |
+
const ids = new Set();
|
| 243 |
+
if (!justification?.facts) return [...ids];
|
| 244 |
+
|
| 245 |
+
// Build meeting-to-assignment lookup
|
| 246 |
+
const meetingToAssignment = new Map();
|
| 247 |
+
if (schedule?.meetingAssignments) {
|
| 248 |
+
for (const a of schedule.meetingAssignments) {
|
| 249 |
+
const meetingId = typeof a.meeting === 'object' ? a.meeting.id : a.meeting;
|
| 250 |
+
meetingToAssignment.set(meetingId, a.id);
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
for (const fact of justification.facts) {
|
| 255 |
+
if (fact.type === 'assignment' && fact.id) {
|
| 256 |
+
ids.add(fact.id);
|
| 257 |
+
} else if (fact.type === 'attendance' && fact.meetingId) {
|
| 258 |
+
const assignmentId = meetingToAssignment.get(fact.meetingId);
|
| 259 |
+
if (assignmentId) ids.add(assignmentId);
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
return [...ids];
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
function getConflictStatus(assignmentId) {
|
| 267 |
+
// Use solver's constraint analysis if available (per-assignment violations)
|
| 268 |
+
if (analyzeCache && analyzeCache.has(assignmentId)) {
|
| 269 |
+
const violations = analyzeCache.get(assignmentId);
|
| 270 |
+
|
| 271 |
+
if (violations.hard.length > 0) {
|
| 272 |
+
return {
|
| 273 |
+
status: 'hard',
|
| 274 |
+
icon: '<span class="fas fa-exclamation-triangle text-danger me-1"></span>',
|
| 275 |
+
style: 'background-color: #fee2e2; border-left: 4px solid #dc3545;',
|
| 276 |
+
reason: violations.hard.map(v => v.constraint).join(', ')
|
| 277 |
+
};
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
if (violations.medium.length > 0) {
|
| 281 |
+
return {
|
| 282 |
+
status: 'medium',
|
| 283 |
+
icon: '<span class="fas fa-exclamation-circle text-warning me-1"></span>',
|
| 284 |
+
style: 'background-color: #fef3c7; border-left: 4px solid #ffc107;',
|
| 285 |
+
reason: violations.medium.map(v => v.constraint).join(', ')
|
| 286 |
+
};
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
// Don't show soft violations in timeline - they're optimization trade-offs
|
| 290 |
+
// (e.g., "Overlapping meetings" fires for ANY parallel meetings, even in different rooms)
|
| 291 |
+
// Users can see soft violations in the "Analyze" modal instead
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
// No hard/medium violations - show green (feasible solution)
|
| 295 |
+
return {
|
| 296 |
+
status: 'ok',
|
| 297 |
+
icon: '<span class="fas fa-check-circle text-success me-1"></span>',
|
| 298 |
+
style: 'background-color: #d1fae5; border-left: 4px solid #10b981;',
|
| 299 |
+
reason: ''
|
| 300 |
+
};
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
function calculatePersonWorkload(schedule) {
|
| 305 |
+
const personMeetingCount = new Map();
|
| 306 |
+
|
| 307 |
+
if (!schedule.meetingAssignments || !schedule.meetings) {
|
| 308 |
+
return personMeetingCount;
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
const meetingMap = new Map();
|
| 312 |
+
schedule.meetings.forEach(m => meetingMap.set(m.id, m));
|
| 313 |
+
|
| 314 |
+
// Count meetings per person (assigned meetings only)
|
| 315 |
+
schedule.meetingAssignments.forEach(assignment => {
|
| 316 |
+
if (assignment.room == null || assignment.startingTimeGrain == null) return;
|
| 317 |
+
|
| 318 |
+
const meeting = typeof assignment.meeting === 'string'
|
| 319 |
+
? meetingMap.get(assignment.meeting)
|
| 320 |
+
: assignment.meeting;
|
| 321 |
+
if (!meeting) return;
|
| 322 |
+
|
| 323 |
+
// Count required attendees
|
| 324 |
+
(meeting.requiredAttendances || []).forEach(att => {
|
| 325 |
+
const personId = att.person?.id || att.person;
|
| 326 |
+
personMeetingCount.set(personId, (personMeetingCount.get(personId) || 0) + 1);
|
| 327 |
+
});
|
| 328 |
+
|
| 329 |
+
// Count preferred attendees
|
| 330 |
+
(meeting.preferredAttendances || []).forEach(att => {
|
| 331 |
+
const personId = att.person?.id || att.person;
|
| 332 |
+
personMeetingCount.set(personId, (personMeetingCount.get(personId) || 0) + 1);
|
| 333 |
+
});
|
| 334 |
+
});
|
| 335 |
+
|
| 336 |
+
return personMeetingCount;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
function getWorkloadBadge(meetingCount) {
|
| 341 |
+
if (meetingCount === 0) {
|
| 342 |
+
return '<span class="badge bg-secondary ms-2" title="No meetings">0</span>';
|
| 343 |
+
} else if (meetingCount <= 5) {
|
| 344 |
+
return `<span class="badge bg-primary ms-2" title="${meetingCount} meetings">${meetingCount}</span>`;
|
| 345 |
+
} else if (meetingCount <= 9) {
|
| 346 |
+
return `<span class="badge bg-info text-dark ms-2" title="${meetingCount} meetings">${meetingCount}</span>`;
|
| 347 |
+
} else {
|
| 348 |
+
return `<span class="badge bg-dark ms-2" title="${meetingCount} meetings - Heavy workload">${meetingCount}</span>`;
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
function analyzeUnassignedReason(meeting, schedule) {
|
| 354 |
+
const reasons = [];
|
| 355 |
+
|
| 356 |
+
const totalAttendees = (meeting.requiredAttendances?.length || 0) + (meeting.preferredAttendances?.length || 0);
|
| 357 |
+
|
| 358 |
+
// Check room capacity
|
| 359 |
+
const largestRoomCapacity = Math.max(...schedule.rooms.map(r => r.capacity));
|
| 360 |
+
if (totalAttendees > largestRoomCapacity) {
|
| 361 |
+
reasons.push(`Needs ${totalAttendees} capacity, largest room has ${largestRoomCapacity}`);
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
// Check if meeting duration is very long
|
| 365 |
+
const durationHours = ((meeting.durationInGrains ?? meeting.duration_in_grains) * 15) / 60;
|
| 366 |
+
if (durationHours > 3) {
|
| 367 |
+
reasons.push(`Long meeting (${durationHours}h) - fewer available slots`);
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
// Check if required attendees are heavily booked
|
| 371 |
+
const meetingMap = new Map();
|
| 372 |
+
schedule.meetings.forEach(m => meetingMap.set(m.id, m));
|
| 373 |
+
|
| 374 |
+
const requiredAttendeeIds = new Set((meeting.requiredAttendances || []).map(a => a.person?.id || a.person));
|
| 375 |
+
let busyAttendeesCount = 0;
|
| 376 |
+
|
| 377 |
+
schedule.meetingAssignments.forEach(assignment => {
|
| 378 |
+
if (assignment.room == null || assignment.startingTimeGrain == null) return;
|
| 379 |
+
if (assignment.meeting === meeting.id) return;
|
| 380 |
+
|
| 381 |
+
const otherMeeting = typeof assignment.meeting === 'string'
|
| 382 |
+
? meetingMap.get(assignment.meeting)
|
| 383 |
+
: assignment.meeting;
|
| 384 |
+
if (!otherMeeting) return;
|
| 385 |
+
|
| 386 |
+
const otherRequiredIds = new Set((otherMeeting.requiredAttendances || []).map(a => a.person?.id || a.person));
|
| 387 |
+
for (const id of requiredAttendeeIds) {
|
| 388 |
+
if (otherRequiredIds.has(id)) {
|
| 389 |
+
busyAttendeesCount++;
|
| 390 |
+
break;
|
| 391 |
+
}
|
| 392 |
+
}
|
| 393 |
+
});
|
| 394 |
+
|
| 395 |
+
if (busyAttendeesCount > 0 && requiredAttendeeIds.size > 0) {
|
| 396 |
+
const percentBusy = Math.round((busyAttendeesCount / schedule.meetingAssignments.filter(a => a.room && a.startingTimeGrain).length) * 100);
|
| 397 |
+
if (percentBusy > 30) {
|
| 398 |
+
reasons.push(`Required attendees have many existing meetings`);
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
// If still solving, add generic reason
|
| 403 |
+
if (reasons.length === 0) {
|
| 404 |
+
reasons.push(`Being optimized by solver`);
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
return reasons;
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
function renderScheduleByRoom(schedule) {
|
| 412 |
+
const unassigned = $("#unassigned");
|
| 413 |
+
unassigned.children().remove();
|
| 414 |
+
byRoomGroupData.clear();
|
| 415 |
+
byRoomItemData.clear();
|
| 416 |
+
|
| 417 |
+
// Check if schedule.rooms exists and is an array
|
| 418 |
+
if (!schedule.rooms || !Array.isArray(schedule.rooms)) {
|
| 419 |
+
console.warn('schedule.rooms is not available or not an array:', schedule.rooms);
|
| 420 |
+
return;
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
$.each(schedule.rooms.sort((e1, e2) => e1.name.localeCompare(e2.name)), (_, room) => {
|
| 424 |
+
let content = `<div class="d-flex flex-column"><div><h5 class="card-title mb-1">${room.name}</h5></div>`;
|
| 425 |
+
byRoomGroupData.add({
|
| 426 |
+
id: room.id,
|
| 427 |
+
content: content,
|
| 428 |
+
});
|
| 429 |
+
});
|
| 430 |
+
|
| 431 |
+
const meetingMap = new Map();
|
| 432 |
+
if (schedule.meetings && Array.isArray(schedule.meetings)) {
|
| 433 |
+
schedule.meetings.forEach(m => meetingMap.set(m.id, m));
|
| 434 |
+
}
|
| 435 |
+
const timeGrainMap = new Map();
|
| 436 |
+
if (schedule.timeGrains && Array.isArray(schedule.timeGrains)) {
|
| 437 |
+
schedule.timeGrains.forEach(t => timeGrainMap.set(t.id, t));
|
| 438 |
+
}
|
| 439 |
+
const roomMap = new Map();
|
| 440 |
+
if (schedule.rooms && Array.isArray(schedule.rooms)) {
|
| 441 |
+
schedule.rooms.forEach(r => roomMap.set(r.id, r));
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
if (!schedule.meetingAssignments || !Array.isArray(schedule.meetingAssignments)) {
|
| 445 |
+
console.warn('schedule.meetingAssignments is not available or not an array:', schedule.meetingAssignments);
|
| 446 |
+
return;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
$.each(schedule.meetingAssignments, (_, assignment) => {
|
| 450 |
+
// Handle both string ID and full object for meeting reference
|
| 451 |
+
const meet = typeof assignment.meeting === 'string' ? meetingMap.get(assignment.meeting) : assignment.meeting;
|
| 452 |
+
// Handle both string ID and full object for room reference
|
| 453 |
+
const room = typeof assignment.room === 'string' ? roomMap.get(assignment.room) : assignment.room;
|
| 454 |
+
// Handle both string ID and full object for timeGrain reference
|
| 455 |
+
const timeGrain = typeof assignment.startingTimeGrain === 'string' ? timeGrainMap.get(assignment.startingTimeGrain) : assignment.startingTimeGrain;
|
| 456 |
+
|
| 457 |
+
// Skip if meeting is not found
|
| 458 |
+
if (!meet) {
|
| 459 |
+
console.warn(`Meeting not found for assignment ${assignment.id}`);
|
| 460 |
+
return;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
if (room == null || timeGrain == null) {
|
| 464 |
+
const durationHours = ((meet.durationInGrains ?? meet.duration_in_grains) * 15) / 60;
|
| 465 |
+
const requiredCount = meet.requiredAttendances?.length || 0;
|
| 466 |
+
const preferredCount = meet.preferredAttendances?.length || 0;
|
| 467 |
+
const totalAttendees = requiredCount + preferredCount;
|
| 468 |
+
|
| 469 |
+
// Analyze why unassigned
|
| 470 |
+
const reasons = analyzeUnassignedReason(meet, schedule);
|
| 471 |
+
|
| 472 |
+
const unassignedElement = $(`<div class="card-body"/>`)
|
| 473 |
+
.append($(`<h5 class="card-title mb-1"/>`).text(meet.topic))
|
| 474 |
+
.append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-clock me-1"></span>${durationHours} hour(s)`))
|
| 475 |
+
.append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-users me-1"></span>${totalAttendees} attendees (${requiredCount} required, ${preferredCount} preferred)`));
|
| 476 |
+
|
| 477 |
+
if (reasons.length > 0) {
|
| 478 |
+
const reasonsList = $(`<div class="mt-2 small"/>`);
|
| 479 |
+
reasonsList.append($(`<span class="text-muted">Possible issues:</span>`));
|
| 480 |
+
reasons.forEach(reason => {
|
| 481 |
+
reasonsList.append($(`<div class="text-warning"/>`).html(`<span class="fas fa-exclamation-circle me-1"></span>${reason}`));
|
| 482 |
+
});
|
| 483 |
+
unassignedElement.append(reasonsList);
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
unassigned.append($(`<div class="col"/>`).append($(`<div class="card h-100"/>`).append(unassignedElement)));
|
| 487 |
+
} else {
|
| 488 |
+
const conflictStatus = getConflictStatus(assignment.id);
|
| 489 |
+
const byRoomElement = $("<div />")
|
| 490 |
+
.append($("<div class='d-flex justify-content-center align-items-center' />")
|
| 491 |
+
.append($(conflictStatus.icon))
|
| 492 |
+
.append($(`<h5 class="card-title mb-1"/>`).text(meet.topic)));
|
| 493 |
+
const startDate = JSJoda.LocalDate.now().withDayOfYear(timeGrain.dayOfYear ?? timeGrain.day_of_year);
|
| 494 |
+
const startTime = JSJoda.LocalTime.of(0, 0, 0, 0)
|
| 495 |
+
.plusMinutes((timeGrain.startingMinuteOfDay ?? timeGrain.starting_minute_of_day));
|
| 496 |
+
const startDateTime = JSJoda.LocalDateTime.of(startDate, startTime);
|
| 497 |
+
const endDateTime = startTime.plusMinutes((meet.durationInGrains ?? meet.duration_in_grains) * 15);
|
| 498 |
+
byRoomItemData.add({
|
| 499 |
+
id: assignment.id,
|
| 500 |
+
group: typeof room === 'string' ? room : room.id,
|
| 501 |
+
content: byRoomElement.html(),
|
| 502 |
+
start: startDateTime.toString(),
|
| 503 |
+
end: endDateTime.toString(),
|
| 504 |
+
style: `min-height: 50px; ${conflictStatus.style}`,
|
| 505 |
+
title: conflictStatus.reason || undefined
|
| 506 |
+
});
|
| 507 |
+
}
|
| 508 |
+
});
|
| 509 |
+
|
| 510 |
+
byRoomTimeline.setWindow(JSJoda.LocalDateTime.now().plusDays(1).withHour(8).toString(),
|
| 511 |
+
JSJoda.LocalDateTime.now().plusDays(1).withHour(17).withMinute(45).toString());
|
| 512 |
+
}
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
function renderScheduleByPerson(schedule) {
|
| 516 |
+
const unassigned = $("#unassigned");
|
| 517 |
+
unassigned.children().remove();
|
| 518 |
+
byPersonGroupData.clear();
|
| 519 |
+
byPersonItemData.clear();
|
| 520 |
+
|
| 521 |
+
// Check if schedule.people exists and is an array
|
| 522 |
+
if (!schedule.people || !Array.isArray(schedule.people)) {
|
| 523 |
+
console.warn('schedule.people is not available or not an array:', schedule.people);
|
| 524 |
+
return;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
// Calculate meeting count per person for workload indicators
|
| 528 |
+
const personMeetingCount = calculatePersonWorkload(schedule);
|
| 529 |
+
|
| 530 |
+
$.each(schedule.people.sort((e1, e2) => e1.fullName.localeCompare(e2.fullName)), (_, person) => {
|
| 531 |
+
const meetingCount = personMeetingCount.get(person.id) || 0;
|
| 532 |
+
const workloadBadge = getWorkloadBadge(meetingCount);
|
| 533 |
+
let content = `<div class="d-flex flex-column">
|
| 534 |
+
<div class="d-flex align-items-center">
|
| 535 |
+
<h5 class="card-title mb-1">${person.fullName}</h5>
|
| 536 |
+
${workloadBadge}
|
| 537 |
+
</div>
|
| 538 |
+
</div>`;
|
| 539 |
+
byPersonGroupData.add({
|
| 540 |
+
id: person.id,
|
| 541 |
+
content: content,
|
| 542 |
+
});
|
| 543 |
+
});
|
| 544 |
+
const meetingMap = new Map();
|
| 545 |
+
if (schedule.meetings && Array.isArray(schedule.meetings)) {
|
| 546 |
+
schedule.meetings.forEach(m => meetingMap.set(m.id, m));
|
| 547 |
+
}
|
| 548 |
+
const timeGrainMap = new Map();
|
| 549 |
+
if (schedule.timeGrains && Array.isArray(schedule.timeGrains)) {
|
| 550 |
+
schedule.timeGrains.forEach(t => timeGrainMap.set(t.id, t));
|
| 551 |
+
}
|
| 552 |
+
const roomMap = new Map();
|
| 553 |
+
if (schedule.rooms && Array.isArray(schedule.rooms)) {
|
| 554 |
+
schedule.rooms.forEach(r => roomMap.set(r.id, r));
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
if (!schedule.meetingAssignments || !Array.isArray(schedule.meetingAssignments)) {
|
| 558 |
+
console.warn('schedule.meetingAssignments is not available or not an array:', schedule.meetingAssignments);
|
| 559 |
+
return;
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
$.each(schedule.meetingAssignments, (_, assignment) => {
|
| 563 |
+
// Handle both string ID and full object for meeting reference
|
| 564 |
+
const meet = typeof assignment.meeting === 'string' ? meetingMap.get(assignment.meeting) : assignment.meeting;
|
| 565 |
+
// Handle both string ID and full object for room reference
|
| 566 |
+
const room = typeof assignment.room === 'string' ? roomMap.get(assignment.room) : assignment.room;
|
| 567 |
+
// Handle both string ID and full object for timeGrain reference
|
| 568 |
+
const timeGrain = typeof assignment.startingTimeGrain === 'string' ? timeGrainMap.get(assignment.startingTimeGrain) : assignment.startingTimeGrain;
|
| 569 |
+
|
| 570 |
+
// Skip if meeting is not found
|
| 571 |
+
if (!meet) {
|
| 572 |
+
console.warn(`Meeting not found for assignment ${assignment.id}`);
|
| 573 |
+
return;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
if (room == null || timeGrain == null) {
|
| 577 |
+
const durationHours = ((meet.durationInGrains ?? meet.duration_in_grains) * 15) / 60;
|
| 578 |
+
const requiredCount = meet.requiredAttendances?.length || 0;
|
| 579 |
+
const preferredCount = meet.preferredAttendances?.length || 0;
|
| 580 |
+
const totalAttendees = requiredCount + preferredCount;
|
| 581 |
+
|
| 582 |
+
// Analyze why unassigned
|
| 583 |
+
const reasons = analyzeUnassignedReason(meet, schedule);
|
| 584 |
+
|
| 585 |
+
const unassignedElement = $(`<div class="card-body"/>`)
|
| 586 |
+
.append($(`<h5 class="card-title mb-1"/>`).text(meet.topic))
|
| 587 |
+
.append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-clock me-1"></span>${durationHours} hour(s)`))
|
| 588 |
+
.append($(`<p class="card-text mb-1"/>`).html(`<span class="fas fa-users me-1"></span>${totalAttendees} attendees (${requiredCount} required, ${preferredCount} preferred)`));
|
| 589 |
+
|
| 590 |
+
if (reasons.length > 0) {
|
| 591 |
+
const reasonsList = $(`<div class="mt-2 small"/>`);
|
| 592 |
+
reasonsList.append($(`<span class="text-muted">Possible issues:</span>`));
|
| 593 |
+
reasons.forEach(reason => {
|
| 594 |
+
reasonsList.append($(`<div class="text-warning"/>`).html(`<span class="fas fa-exclamation-circle me-1"></span>${reason}`));
|
| 595 |
+
});
|
| 596 |
+
unassignedElement.append(reasonsList);
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
unassigned.append($(`<div class="col"/>`).append($(`<div class="card h-100"/>`).append(unassignedElement)));
|
| 600 |
+
} else {
|
| 601 |
+
const conflictStatus = getConflictStatus(assignment.id);
|
| 602 |
+
const startDate = JSJoda.LocalDate.now().withDayOfYear(timeGrain.dayOfYear ?? timeGrain.day_of_year);
|
| 603 |
+
const startTime = JSJoda.LocalTime.of(0, 0, 0, 0)
|
| 604 |
+
.plusMinutes((timeGrain.startingMinuteOfDay ?? timeGrain.starting_minute_of_day));
|
| 605 |
+
const startDateTime = JSJoda.LocalDateTime.of(startDate, startTime);
|
| 606 |
+
const endDateTime = startTime.plusMinutes((meet.durationInGrains ?? meet.duration_in_grains) * 15);
|
| 607 |
+
meet.requiredAttendances.forEach(attendance => {
|
| 608 |
+
const byPersonElement = $("<div />")
|
| 609 |
+
.append($("<div class='d-flex justify-content-center align-items-center' />")
|
| 610 |
+
.append($(conflictStatus.icon))
|
| 611 |
+
.append($(`<h5 class="card-title mb-1"/>`).text(meet.topic)));
|
| 612 |
+
byPersonElement.append($("<div class='d-flex justify-content-center' />").append($(`<span class="badge text-bg-success m-1" style="background-color: ${pickColor(meet.id)}" />`).text("Required")));
|
| 613 |
+
if (meet.preferredAttendances.map(a => a.person).indexOf(attendance.person) >= 0) {
|
| 614 |
+
byPersonElement.append($("<div class='d-flex justify-content-center' />").append($(`<span class="badge text-bg-info m-1" style="background-color: ${pickColor(meet.id)}" />`).text("Preferred")));
|
| 615 |
+
}
|
| 616 |
+
byPersonItemData.add({
|
| 617 |
+
id: `${assignment.id}-${attendance.person.id}`,
|
| 618 |
+
group: attendance.person.id,
|
| 619 |
+
content: byPersonElement.html(),
|
| 620 |
+
start: startDateTime.toString(),
|
| 621 |
+
end: endDateTime.toString(),
|
| 622 |
+
style: `min-height: 50px; ${conflictStatus.style}`,
|
| 623 |
+
title: conflictStatus.reason || undefined
|
| 624 |
+
});
|
| 625 |
+
});
|
| 626 |
+
meet.preferredAttendances.forEach(attendance => {
|
| 627 |
+
if (meet.requiredAttendances.map(a => a.person).indexOf(attendance.person) === -1) {
|
| 628 |
+
const byPersonElement = $("<div />")
|
| 629 |
+
.append($("<div class='d-flex justify-content-center align-items-center' />")
|
| 630 |
+
.append($(conflictStatus.icon))
|
| 631 |
+
.append($(`<h5 class="card-title mb-1"/>`).text(meet.topic)));
|
| 632 |
+
byPersonElement.append($("<div class='d-flex justify-content-center' />").append($(`<span class="badge text-bg-info m-1" style="background-color: ${pickColor(meet.id)}" />`).text("Preferred")));
|
| 633 |
+
byPersonItemData.add({
|
| 634 |
+
id: `${assignment.id}-${attendance.person.id}`,
|
| 635 |
+
group: attendance.person.id,
|
| 636 |
+
content: byPersonElement.html(),
|
| 637 |
+
start: startDateTime.toString(),
|
| 638 |
+
end: endDateTime.toString(),
|
| 639 |
+
style: `min-height: 50px; ${conflictStatus.style}`,
|
| 640 |
+
title: conflictStatus.reason || undefined
|
| 641 |
+
});
|
| 642 |
+
}
|
| 643 |
+
});
|
| 644 |
+
}
|
| 645 |
+
});
|
| 646 |
+
|
| 647 |
+
byPersonTimeline.setWindow(JSJoda.LocalDateTime.now().plusDays(1).withHour(8).toString(),
|
| 648 |
+
JSJoda.LocalDateTime.now().plusDays(1).withHour(17).withMinute(45).toString());
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
// Click handlers for timeline items
|
| 653 |
+
byRoomTimeline.on('select', function (properties) {
|
| 654 |
+
if (properties.items.length > 0) {
|
| 655 |
+
showMeetingDetails(properties.items[0]);
|
| 656 |
+
}
|
| 657 |
+
});
|
| 658 |
+
|
| 659 |
+
byPersonTimeline.on('select', function (properties) {
|
| 660 |
+
if (properties.items.length > 0) {
|
| 661 |
+
// For person view, item id is "assignmentId-personId", extract assignmentId
|
| 662 |
+
const itemId = properties.items[0];
|
| 663 |
+
const assignmentId = itemId.includes('-') ? itemId.split('-').slice(0, -1).join('-') : itemId;
|
| 664 |
+
showMeetingDetails(assignmentId);
|
| 665 |
+
}
|
| 666 |
+
});
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
function showMeetingDetails(assignmentId) {
|
| 670 |
+
if (!loadedSchedule) return;
|
| 671 |
+
|
| 672 |
+
// Find the assignment
|
| 673 |
+
const assignment = loadedSchedule.meetingAssignments.find(a => a.id === assignmentId);
|
| 674 |
+
if (!assignment) {
|
| 675 |
+
console.warn('Assignment not found:', assignmentId);
|
| 676 |
+
return;
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
// Build lookup maps
|
| 680 |
+
const meetingMap = new Map();
|
| 681 |
+
loadedSchedule.meetings.forEach(m => meetingMap.set(m.id, m));
|
| 682 |
+
const roomMap = new Map();
|
| 683 |
+
loadedSchedule.rooms.forEach(r => roomMap.set(r.id, r));
|
| 684 |
+
const personMap = new Map();
|
| 685 |
+
loadedSchedule.people.forEach(p => personMap.set(p.id, p));
|
| 686 |
+
|
| 687 |
+
// Get meeting and room details
|
| 688 |
+
const meeting = typeof assignment.meeting === 'string' ? meetingMap.get(assignment.meeting) : assignment.meeting;
|
| 689 |
+
const room = typeof assignment.room === 'string' ? roomMap.get(assignment.room) : assignment.room;
|
| 690 |
+
const timeGrain = typeof assignment.startingTimeGrain === 'string'
|
| 691 |
+
? loadedSchedule.timeGrains.find(t => t.id === assignment.startingTimeGrain)
|
| 692 |
+
: assignment.startingTimeGrain;
|
| 693 |
+
|
| 694 |
+
if (!meeting) {
|
| 695 |
+
console.warn('Meeting not found for assignment:', assignmentId);
|
| 696 |
+
return;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
// Get conflict status
|
| 700 |
+
const conflictStatus = getConflictStatus(assignmentId);
|
| 701 |
+
|
| 702 |
+
// Build modal content
|
| 703 |
+
const content = $("#meetingDetailsModalContent");
|
| 704 |
+
content.empty();
|
| 705 |
+
|
| 706 |
+
// Meeting title and status
|
| 707 |
+
const statusBadge = conflictStatus.status === 'hard'
|
| 708 |
+
? '<span class="badge bg-danger ms-2">Hard Conflict</span>'
|
| 709 |
+
: conflictStatus.status === 'medium'
|
| 710 |
+
? '<span class="badge bg-warning ms-2">Medium Issue</span>'
|
| 711 |
+
: conflictStatus.status === 'soft'
|
| 712 |
+
? '<span class="badge bg-info ms-2">Soft Issue</span>'
|
| 713 |
+
: '<span class="badge bg-success ms-2">OK</span>';
|
| 714 |
+
|
| 715 |
+
content.append($('<h4/>').html(meeting.topic + statusBadge));
|
| 716 |
+
|
| 717 |
+
// Show reason if any
|
| 718 |
+
if (conflictStatus.reason) {
|
| 719 |
+
content.append($('<div class="alert alert-info py-2"/>').text(conflictStatus.reason));
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
// Details table
|
| 723 |
+
const detailsTable = $('<table class="table table-sm"/>');
|
| 724 |
+
const tbody = $('<tbody/>');
|
| 725 |
+
|
| 726 |
+
// Duration
|
| 727 |
+
const durationHours = ((meeting.durationInGrains ?? meeting.duration_in_grains) * 15) / 60;
|
| 728 |
+
tbody.append($('<tr/>')
|
| 729 |
+
.append($('<th scope="row" style="width: 150px"/>').text('Duration'))
|
| 730 |
+
.append($('<td/>').text(`${durationHours} hour(s) (${(meeting.durationInGrains ?? meeting.duration_in_grains)} time grains)`)));
|
| 731 |
+
|
| 732 |
+
// Room
|
| 733 |
+
if (room) {
|
| 734 |
+
tbody.append($('<tr/>')
|
| 735 |
+
.append($('<th scope="row"/>').text('Room'))
|
| 736 |
+
.append($('<td/>').text(`${room.name} (capacity: ${room.capacity})`)));
|
| 737 |
+
} else {
|
| 738 |
+
tbody.append($('<tr/>')
|
| 739 |
+
.append($('<th scope="row"/>').text('Room'))
|
| 740 |
+
.append($('<td/>').html('<span class="text-danger">Not assigned</span>')));
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
// Time
|
| 744 |
+
if (timeGrain) {
|
| 745 |
+
const startDate = JSJoda.LocalDate.now().withDayOfYear(timeGrain.dayOfYear ?? timeGrain.day_of_year);
|
| 746 |
+
const startTime = JSJoda.LocalTime.of(0, 0, 0, 0).plusMinutes((timeGrain.startingMinuteOfDay ?? timeGrain.starting_minute_of_day));
|
| 747 |
+
const endTime = startTime.plusMinutes((meeting.durationInGrains ?? meeting.duration_in_grains) * 15);
|
| 748 |
+
tbody.append($('<tr/>')
|
| 749 |
+
.append($('<th scope="row"/>').text('Time'))
|
| 750 |
+
.append($('<td/>').text(`${startDate.toString()} ${startTime.toString()} - ${endTime.toString()}`)));
|
| 751 |
+
} else {
|
| 752 |
+
tbody.append($('<tr/>')
|
| 753 |
+
.append($('<th scope="row"/>').text('Time'))
|
| 754 |
+
.append($('<td/>').html('<span class="text-danger">Not scheduled</span>')));
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
detailsTable.append(tbody);
|
| 758 |
+
content.append(detailsTable);
|
| 759 |
+
|
| 760 |
+
// Required Attendees section
|
| 761 |
+
content.append($('<h5 class="mt-3"/>').text('Required Attendees'));
|
| 762 |
+
if (meeting.requiredAttendances && meeting.requiredAttendances.length > 0) {
|
| 763 |
+
const reqList = $('<ul class="list-group list-group-flush"/>');
|
| 764 |
+
meeting.requiredAttendances.forEach(att => {
|
| 765 |
+
const person = att.person?.fullName || (personMap.get(att.person)?.fullName) || att.person;
|
| 766 |
+
reqList.append($('<li class="list-group-item py-1"/>')
|
| 767 |
+
.html(`<span class="fas fa-user me-2 text-success"></span>${person}`));
|
| 768 |
+
});
|
| 769 |
+
content.append(reqList);
|
| 770 |
+
} else {
|
| 771 |
+
content.append($('<p class="text-muted"/>').text('No required attendees'));
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
// Preferred Attendees section
|
| 775 |
+
content.append($('<h5 class="mt-3"/>').text('Preferred Attendees'));
|
| 776 |
+
if (meeting.preferredAttendances && meeting.preferredAttendances.length > 0) {
|
| 777 |
+
const prefList = $('<ul class="list-group list-group-flush"/>');
|
| 778 |
+
meeting.preferredAttendances.forEach(att => {
|
| 779 |
+
const person = att.person?.fullName || (personMap.get(att.person)?.fullName) || att.person;
|
| 780 |
+
prefList.append($('<li class="list-group-item py-1"/>')
|
| 781 |
+
.html(`<span class="fas fa-user me-2 text-info"></span>${person}`));
|
| 782 |
+
});
|
| 783 |
+
content.append(prefList);
|
| 784 |
+
} else {
|
| 785 |
+
content.append($('<p class="text-muted"/>').text('No preferred attendees'));
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
// Conflict details section
|
| 789 |
+
if (conflictStatus.status !== 'ok' && analyzeCache && analyzeCache.has(assignmentId)) {
|
| 790 |
+
content.append($('<h5 class="mt-3 text-danger"/>').text('Conflicts'));
|
| 791 |
+
const conflictList = $('<ul class="list-group list-group-flush"/>');
|
| 792 |
+
|
| 793 |
+
const violations = analyzeCache.get(assignmentId);
|
| 794 |
+
|
| 795 |
+
// Show hard violations
|
| 796 |
+
violations.hard.forEach(v => {
|
| 797 |
+
conflictList.append($('<li class="list-group-item py-1 text-danger"/>')
|
| 798 |
+
.html(`<span class="fas fa-exclamation-triangle me-2"></span>${v.constraint}`));
|
| 799 |
+
});
|
| 800 |
+
|
| 801 |
+
// Show medium violations
|
| 802 |
+
violations.medium.forEach(v => {
|
| 803 |
+
conflictList.append($('<li class="list-group-item py-1 text-warning"/>')
|
| 804 |
+
.html(`<span class="fas fa-exclamation-circle me-2"></span>${v.constraint}`));
|
| 805 |
+
});
|
| 806 |
+
|
| 807 |
+
// Show soft violations
|
| 808 |
+
violations.soft.forEach(v => {
|
| 809 |
+
conflictList.append($('<li class="list-group-item py-1 text-info"/>')
|
| 810 |
+
.html(`<span class="fas fa-info-circle me-2"></span>${v.constraint}`));
|
| 811 |
+
});
|
| 812 |
+
|
| 813 |
+
content.append(conflictList);
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
// Update modal title
|
| 817 |
+
$("#meetingDetailsModalLabel").text("Meeting Details: " + meeting.topic);
|
| 818 |
+
|
| 819 |
+
// Show modal
|
| 820 |
+
new bootstrap.Modal("#meetingDetailsModal").show();
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
function solve() {
|
| 825 |
+
if (!loadedSchedule) {
|
| 826 |
+
showError("No schedule data loaded. Please wait for the data to load or refresh the page.");
|
| 827 |
+
return;
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
console.log('Sending schedule data for solving:', loadedSchedule);
|
| 831 |
+
$.post("/schedules", JSON.stringify(loadedSchedule), function (data) {
|
| 832 |
+
scheduleId = data;
|
| 833 |
+
refreshSolvingButtons(true);
|
| 834 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 835 |
+
showError("Start solving failed.", xhr);
|
| 836 |
+
refreshSolvingButtons(false);
|
| 837 |
+
}, "text");
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
function analyze() {
|
| 842 |
+
new bootstrap.Modal("#scoreAnalysisModal").show()
|
| 843 |
+
const scoreAnalysisModalContent = $("#scoreAnalysisModalContent");
|
| 844 |
+
scoreAnalysisModalContent.children().remove();
|
| 845 |
+
if (loadedSchedule.score == null) {
|
| 846 |
+
scoreAnalysisModalContent.text("No score to analyze yet, please first press the 'solve' button.");
|
| 847 |
+
} else {
|
| 848 |
+
$('#scoreAnalysisScoreLabel').text(`(${loadedSchedule.score})`);
|
| 849 |
+
$.put("/schedules/analyze", JSON.stringify(loadedSchedule), function (scoreAnalysis) {
|
| 850 |
+
let constraints = scoreAnalysis.constraints;
|
| 851 |
+
constraints.sort((a, b) => {
|
| 852 |
+
let aComponents = getScoreComponents(a.score), bComponents = getScoreComponents(b.score);
|
| 853 |
+
if (aComponents.hard < 0 && bComponents.hard > 0) return -1;
|
| 854 |
+
if (aComponents.hard > 0 && bComponents.soft < 0) return 1;
|
| 855 |
+
if (Math.abs(aComponents.hard) > Math.abs(bComponents.hard)) {
|
| 856 |
+
return -1;
|
| 857 |
+
} else {
|
| 858 |
+
if (aComponents.medium < 0 && bComponents.medium > 0) return -1;
|
| 859 |
+
if (aComponents.medium > 0 && bComponents.medium < 0) return 1;
|
| 860 |
+
if (Math.abs(aComponents.medium) > Math.abs(bComponents.medium)) {
|
| 861 |
+
return -1;
|
| 862 |
+
} else {
|
| 863 |
+
if (aComponents.soft < 0 && bComponents.soft > 0) return -1;
|
| 864 |
+
if (aComponents.soft > 0 && bComponents.soft < 0) return 1;
|
| 865 |
+
|
| 866 |
+
return Math.abs(bComponents.soft) - Math.abs(aComponents.soft);
|
| 867 |
+
}
|
| 868 |
+
}
|
| 869 |
+
});
|
| 870 |
+
constraints.map((e) => {
|
| 871 |
+
let components = getScoreComponents(e.weight);
|
| 872 |
+
e.type = components.hard != 0 ? 'hard' : (components.medium != 0 ? 'medium' : 'soft');
|
| 873 |
+
e.weight = components[e.type];
|
| 874 |
+
let scores = getScoreComponents(e.score);
|
| 875 |
+
e.implicitScore = scores.hard != 0 ? scores.hard : (scores.medium != 0 ? scores.medium : scores.soft);
|
| 876 |
+
});
|
| 877 |
+
scoreAnalysis.constraints = constraints;
|
| 878 |
+
|
| 879 |
+
scoreAnalysisModalContent.children().remove();
|
| 880 |
+
scoreAnalysisModalContent.text("");
|
| 881 |
+
|
| 882 |
+
const analysisTable = $(`<table class="table"/>`).css({textAlign: 'center'});
|
| 883 |
+
const analysisTHead = $(`<thead/>`).append($(`<tr/>`)
|
| 884 |
+
.append($(`<th></th>`))
|
| 885 |
+
.append($(`<th>Constraint</th>`).css({textAlign: 'left'}))
|
| 886 |
+
.append($(`<th>Type</th>`))
|
| 887 |
+
.append($(`<th># Matches</th>`))
|
| 888 |
+
.append($(`<th>Weight</th>`))
|
| 889 |
+
.append($(`<th>Score</th>`))
|
| 890 |
+
.append($(`<th></th>`)));
|
| 891 |
+
analysisTable.append(analysisTHead);
|
| 892 |
+
const analysisTBody = $(`<tbody/>`)
|
| 893 |
+
$.each(scoreAnalysis.constraints, (index, constraintAnalysis) => {
|
| 894 |
+
let icon = constraintAnalysis.type == "hard" && constraintAnalysis.implicitScore < 0 ? '<span class="fas fa-exclamation-triangle" style="color: red"></span>' : '';
|
| 895 |
+
if (!icon) icon = constraintAnalysis.matches.length == 0 ? '<span class="fas fa-check-circle" style="color: green"></span>' : '';
|
| 896 |
+
|
| 897 |
+
let row = $(`<tr/>`);
|
| 898 |
+
row.append($(`<td/>`).html(icon))
|
| 899 |
+
.append($(`<td/>`).text(constraintAnalysis.name).css({textAlign: 'left'}))
|
| 900 |
+
.append($(`<td/>`).text(constraintAnalysis.type))
|
| 901 |
+
.append($(`<td/>`).html(`<b>${constraintAnalysis.matches.length}</b>`))
|
| 902 |
+
.append($(`<td/>`).text(constraintAnalysis.weight))
|
| 903 |
+
.append($(`<td/>`).text(constraintAnalysis.implicitScore));
|
| 904 |
+
analysisTBody.append(row);
|
| 905 |
+
row.append($(`<td/>`));
|
| 906 |
+
});
|
| 907 |
+
analysisTable.append(analysisTBody);
|
| 908 |
+
scoreAnalysisModalContent.append(analysisTable);
|
| 909 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 910 |
+
showError("Analyze failed.", xhr);
|
| 911 |
+
}, "text");
|
| 912 |
+
}
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
function getScoreComponents(score) {
|
| 917 |
+
let components = {hard: 0, medium: 0, soft: 0};
|
| 918 |
+
|
| 919 |
+
$.each([...score.matchAll(/(-?[0-9]+)(hard|medium|soft)/g)], (i, parts) => {
|
| 920 |
+
components[parts[2]] = parseInt(parts[1], 10);
|
| 921 |
+
});
|
| 922 |
+
|
| 923 |
+
return components;
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
|
| 927 |
+
function refreshSolvingButtons(solving) {
|
| 928 |
+
if (solving) {
|
| 929 |
+
$("#solveButton").hide();
|
| 930 |
+
$("#stopSolvingButton").show();
|
| 931 |
+
$("#solvingSpinner").addClass("active");
|
| 932 |
+
if (autoRefreshIntervalId == null) {
|
| 933 |
+
autoRefreshIntervalId = setInterval(refreshSchedule, 2000);
|
| 934 |
+
}
|
| 935 |
+
} else {
|
| 936 |
+
$("#solveButton").show();
|
| 937 |
+
$("#stopSolvingButton").hide();
|
| 938 |
+
$("#solvingSpinner").removeClass("active");
|
| 939 |
+
if (autoRefreshIntervalId != null) {
|
| 940 |
+
clearInterval(autoRefreshIntervalId);
|
| 941 |
+
autoRefreshIntervalId = null;
|
| 942 |
+
}
|
| 943 |
+
}
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
function stopSolving() {
|
| 947 |
+
$.delete("/schedules/" + scheduleId, function () {
|
| 948 |
+
refreshSolvingButtons(false);
|
| 949 |
+
refreshSchedule();
|
| 950 |
+
}).fail(function (xhr, ajaxOptions, thrownError) {
|
| 951 |
+
showError("Stop solving failed.", xhr);
|
| 952 |
+
});
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
function copyTextToClipboard(id) {
|
| 957 |
+
var text = $("#" + id).text().trim();
|
| 958 |
+
|
| 959 |
+
var dummy = document.createElement("textarea");
|
| 960 |
+
document.body.appendChild(dummy);
|
| 961 |
+
dummy.value = text;
|
| 962 |
+
dummy.select();
|
| 963 |
+
document.execCommand("copy");
|
| 964 |
+
document.body.removeChild(dummy);
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
|
| 968 |
+
function replaceQuickstartSolverForgeAutoHeaderFooter() {
|
| 969 |
+
const solverforgeHeader = $("header#solverforge-auto-header");
|
| 970 |
+
if (solverforgeHeader != null) {
|
| 971 |
+
solverforgeHeader.css("background-color", "#ffffff");
|
| 972 |
+
solverforgeHeader.append(
|
| 973 |
+
$(`<div class="container-fluid">
|
| 974 |
+
<nav class="navbar sticky-top navbar-expand-lg shadow-sm mb-3" style="background-color: #ffffff;">
|
| 975 |
+
<a class="navbar-brand" href="https://www.solverforge.org">
|
| 976 |
+
<img src="/webjars/solverforge/img/solverforge-horizontal.svg" alt="SolverForge logo" width="400">
|
| 977 |
+
</a>
|
| 978 |
+
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
| 979 |
+
<span class="navbar-toggler-icon"></span>
|
| 980 |
+
</button>
|
| 981 |
+
<div class="collapse navbar-collapse" id="navbarNav">
|
| 982 |
+
<ul class="nav nav-pills">
|
| 983 |
+
<li class="nav-item active" id="navUIItem">
|
| 984 |
+
<button class="nav-link active" id="navUI" data-bs-toggle="pill" data-bs-target="#demo" type="button" style="color: #1f2937;">Demo UI</button>
|
| 985 |
+
</li>
|
| 986 |
+
<li class="nav-item" id="navRestItem">
|
| 987 |
+
<button class="nav-link" id="navRest" data-bs-toggle="pill" data-bs-target="#rest" type="button" style="color: #1f2937;">Guide</button>
|
| 988 |
+
</li>
|
| 989 |
+
<li class="nav-item" id="navOpenApiItem">
|
| 990 |
+
<button class="nav-link" id="navOpenApi" data-bs-toggle="pill" data-bs-target="#openapi" type="button" style="color: #1f2937;">REST API</button>
|
| 991 |
+
</li>
|
| 992 |
+
</ul>
|
| 993 |
+
</div>
|
| 994 |
+
<div class="ms-auto">
|
| 995 |
+
<div class="dropdown">
|
| 996 |
+
<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;">
|
| 997 |
+
Data
|
| 998 |
+
</button>
|
| 999 |
+
<div id="testDataButton" class="dropdown-menu" aria-labelledby="dropdownMenuButton"></div>
|
| 1000 |
+
</div>
|
| 1001 |
+
</div>
|
| 1002 |
+
</nav>
|
| 1003 |
+
</div>`));
|
| 1004 |
+
}
|
| 1005 |
+
|
| 1006 |
+
const solverforgeFooter = $("footer#solverforge-auto-footer");
|
| 1007 |
+
if (solverforgeFooter != null) {
|
| 1008 |
+
solverforgeFooter.append(
|
| 1009 |
+
$(`<footer class="bg-black text-white-50">
|
| 1010 |
+
<div class="container">
|
| 1011 |
+
<div class="hstack gap-3 p-4">
|
| 1012 |
+
<div class="ms-auto"><a class="text-white" href="https://www.solverforge.org">SolverForge</a></div>
|
| 1013 |
+
<div class="vr"></div>
|
| 1014 |
+
<div><a class="text-white" href="https://www.solverforge.org/docs">Documentation</a></div>
|
| 1015 |
+
<div class="vr"></div>
|
| 1016 |
+
<div><a class="text-white" href="https://github.com/SolverForge/solverforge-legacy">Code</a></div>
|
| 1017 |
+
<div class="vr"></div>
|
| 1018 |
+
<div class="me-auto"><a class="text-white" href="mailto:info@solverforge.org">Support</a></div>
|
| 1019 |
+
</div>
|
| 1020 |
+
</div>
|
| 1021 |
+
</footer>`));
|
| 1022 |
+
}
|
| 1023 |
+
}
|
static/index.html
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type">
|
| 5 |
+
<meta content="width=device-width, initial-scale=1" name="viewport">
|
| 6 |
+
<title>Meeting Scheduling - SolverForge for Python</title>
|
| 7 |
+
|
| 8 |
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/styles/vis-timeline-graph2d.min.css"
|
| 9 |
+
integrity="sha256-svzNasPg1yR5gvEaRei2jg+n4Pc3sVyMUWeS6xRAh6U=" crossorigin="anonymous">
|
| 10 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/css/bootstrap.min.css"/>
|
| 11 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"/>
|
| 12 |
+
<link rel="stylesheet" href="/webjars/solverforge/css/solverforge-webui.css"/>
|
| 13 |
+
<style>
|
| 14 |
+
.vis-time-axis .vis-grid.vis-saturday,
|
| 15 |
+
.vis-time-axis .vis-grid.vis-sunday {
|
| 16 |
+
background: #D3D7CFFF;
|
| 17 |
+
}
|
| 18 |
+
.vis-item-content {
|
| 19 |
+
width: 100%;
|
| 20 |
+
}
|
| 21 |
+
#byPersonPanel, #byRoomPanel {
|
| 22 |
+
min-height: 300px;
|
| 23 |
+
}
|
| 24 |
+
</style>
|
| 25 |
+
<link rel="icon" href="/webjars/solverforge/img/solverforge-favicon.svg" type="image/svg+xml">
|
| 26 |
+
</head>
|
| 27 |
+
<body>
|
| 28 |
+
<header id="solverforge-auto-header">
|
| 29 |
+
<!-- Filled in by app.js -->
|
| 30 |
+
</header>
|
| 31 |
+
<div class="tab-content">
|
| 32 |
+
<div id="demo" class="tab-pane fade show active container-fluid">
|
| 33 |
+
<div class="sticky-top d-flex justify-content-center align-items-center" aria-live="polite" aria-atomic="true">
|
| 34 |
+
<div id="notificationPanel" style="position: absolute; top: .5rem;"></div>
|
| 35 |
+
</div>
|
| 36 |
+
<h1>Meeting Scheduling Solver</h1>
|
| 37 |
+
<p>Generate the optimal schedule for your meeting scheduling.</p>
|
| 38 |
+
|
| 39 |
+
<div class="mb-2">
|
| 40 |
+
<button id="solveButton" type="button" class="btn btn-success">
|
| 41 |
+
<span class="fas fa-play"></span> Solve
|
| 42 |
+
</button>
|
| 43 |
+
<button id="stopSolvingButton" type="button" class="btn btn-danger">
|
| 44 |
+
<span class="fas fa-stop"></span> Stop solving
|
| 45 |
+
</button>
|
| 46 |
+
<span id="score" class="score ms-2 align-middle fw-bold">Score: ?</span>
|
| 47 |
+
<button id="analyzeButton" type="button" class="ms-2 btn btn-secondary">
|
| 48 |
+
<span class="fas fa-question"></span>
|
| 49 |
+
</button>
|
| 50 |
+
|
| 51 |
+
<div class="float-end">
|
| 52 |
+
<ul class="nav nav-pills" role="tablist">
|
| 53 |
+
<li class="nav-item" role="presentation">
|
| 54 |
+
<button class="nav-link active" id="byRoomTab" data-bs-toggle="tab"
|
| 55 |
+
data-bs-target="#byRoomPanel" type="button" role="tab" aria-controls="byRoomPanel"
|
| 56 |
+
aria-selected="true">By Room
|
| 57 |
+
</button>
|
| 58 |
+
</li>
|
| 59 |
+
<li class="nav-item" role="presentation">
|
| 60 |
+
<button class="nav-link" id="byPersonTab" data-bs-toggle="tab"
|
| 61 |
+
data-bs-target="#byPersonPanel" type="button" role="tab" aria-controls="byPersonPanel"
|
| 62 |
+
aria-selected="true">By Person
|
| 63 |
+
</button>
|
| 64 |
+
</li>
|
| 65 |
+
</ul>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
<div class="tab-content">
|
| 69 |
+
<div class="tab-pane fade show active" id="byRoomPanel" role="tabpanel" aria-labelledby="byRoomTab">
|
| 70 |
+
<!-- Timeline will be rendered directly here -->
|
| 71 |
+
</div>
|
| 72 |
+
<div class="tab-pane fade" id="byPersonPanel" role="tabpanel" aria-labelledby="byPersonTab">
|
| 73 |
+
<!-- Timeline will be rendered directly here -->
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
|
| 77 |
+
<h2>Unassigned</h2>
|
| 78 |
+
<div id="unassigned" class="row row-cols-4 g-3 mb-4"></div>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<div id="rest" class="tab-pane fade container-fluid">
|
| 82 |
+
<h1>REST API Guide</h1>
|
| 83 |
+
|
| 84 |
+
<h2>Meeting Scheduling solver integration via cURL</h2>
|
| 85 |
+
|
| 86 |
+
<h3>1. Download demo data</h3>
|
| 87 |
+
<pre>
|
| 88 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 89 |
+
onclick="copyTextToClipboard('curl1')">Copy</button>
|
| 90 |
+
<code id="curl1">curl -X GET -H 'Accept:application/json' http://localhost:8080/demo-data -o sample.json</code>
|
| 91 |
+
</pre>
|
| 92 |
+
|
| 93 |
+
<h3>2. Post the sample data for solving</h3>
|
| 94 |
+
<p>The POST operation returns a <code>jobId</code> that should be used in subsequent commands.</p>
|
| 95 |
+
<pre>
|
| 96 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 97 |
+
onclick="copyTextToClipboard('curl2')">Copy</button>
|
| 98 |
+
<code id="curl2">curl -X POST -H 'Content-Type:application/json' http://localhost:8080/schedules -d@sample.json</code>
|
| 99 |
+
</pre>
|
| 100 |
+
|
| 101 |
+
<h3>3. Get the current status and score</h3>
|
| 102 |
+
<pre>
|
| 103 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 104 |
+
onclick="copyTextToClipboard('curl3')">Copy</button>
|
| 105 |
+
<code id="curl3">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}/status</code>
|
| 106 |
+
</pre>
|
| 107 |
+
|
| 108 |
+
<h3>4. Get the complete solution</h3>
|
| 109 |
+
<pre>
|
| 110 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 111 |
+
onclick="copyTextToClipboard('curl4')">Copy</button>
|
| 112 |
+
<code id="curl4">curl -X GET -H 'Accept:application/json' http://localhost:8080/schedules/{jobId} -o solution.json</code>
|
| 113 |
+
</pre>
|
| 114 |
+
|
| 115 |
+
<h3>5. Fetch the analysis of the solution</h3>
|
| 116 |
+
<pre>
|
| 117 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 118 |
+
onclick="copyTextToClipboard('curl5')">Copy</button>
|
| 119 |
+
<code id="curl5">curl -X PUT -H 'Content-Type:application/json' http://localhost:8080/schedules/analyze -d@solution.json</code>
|
| 120 |
+
</pre>
|
| 121 |
+
|
| 122 |
+
<h3>6. Terminate solving early</h3>
|
| 123 |
+
<pre>
|
| 124 |
+
<button class="btn btn-outline-dark btn-sm float-end"
|
| 125 |
+
onclick="copyTextToClipboard('curl6')">Copy</button>
|
| 126 |
+
<code id="curl6">curl -X DELETE -H 'Accept:application/json' http://localhost:8080/schedules/{jobId}</code>
|
| 127 |
+
</pre>
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
<div id="openapi" class="tab-pane fade container-fluid">
|
| 131 |
+
<h1>REST API Reference</h1>
|
| 132 |
+
<div class="ratio ratio-1x1">
|
| 133 |
+
<!-- "scrolling" attribute is obsolete, but e.g. Chrome does not support "overflow:hidden" -->
|
| 134 |
+
<iframe src="/q/swagger-ui" style="overflow:hidden;" scrolling="no"></iframe>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
<footer id="solverforge-auto-footer"></footer>
|
| 139 |
+
|
| 140 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/2.11.8/umd/popper.min.js"></script>
|
| 141 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.3/js/bootstrap.min.js"></script>
|
| 142 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
| 143 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-joda/1.11.0/js-joda.min.js"></script>
|
| 144 |
+
<script src="/webjars/solverforge/js/solverforge-webui.js"></script>
|
| 145 |
+
<script src="https://cdn.jsdelivr.net/npm/vis-timeline@7.7.2/standalone/umd/vis-timeline-graph2d.min.js"
|
| 146 |
+
integrity="sha256-Jy2+UO7rZ2Dgik50z3XrrNpnc5+2PAx9MhL2CicodME=" crossorigin="anonymous"></script>
|
| 147 |
+
<script src="/app.js"></script>
|
| 148 |
+
|
| 149 |
+
<!-- Meeting Details Modal -->
|
| 150 |
+
<div class="modal fade" id="meetingDetailsModal" tabindex="-1" aria-labelledby="meetingDetailsModalLabel" aria-hidden="true">
|
| 151 |
+
<div class="modal-dialog modal-lg">
|
| 152 |
+
<div class="modal-content">
|
| 153 |
+
<div class="modal-header">
|
| 154 |
+
<h5 class="modal-title" id="meetingDetailsModalLabel">Meeting Details</h5>
|
| 155 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
| 156 |
+
</div>
|
| 157 |
+
<div class="modal-body" id="meetingDetailsModalContent">
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
|
| 163 |
+
<!-- Score Analysis Modal -->
|
| 164 |
+
<div class="modal fade" id="scoreAnalysisModal" tabindex="-1" aria-labelledby="scoreAnalysisModalLabel" aria-hidden="true">
|
| 165 |
+
<div class="modal-dialog modal-lg">
|
| 166 |
+
<div class="modal-content">
|
| 167 |
+
<div class="modal-header">
|
| 168 |
+
<h5 class="modal-title" id="scoreAnalysisModalLabel">Score Analysis <span id="scoreAnalysisScoreLabel"></span></h5>
|
| 169 |
+
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
| 170 |
+
</div>
|
| 171 |
+
<div class="modal-body" id="scoreAnalysisModalContent">
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
</body>
|
| 177 |
+
</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 |
+
}
|
static/webjars/timefold/css/timefold-webui.css
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
/* Keep in sync with .navbar height on a large screen. */
|
| 3 |
+
--ts-navbar-height: 109px;
|
| 4 |
+
|
| 5 |
+
--ts-violet-1-rgb: #3E00FF;
|
| 6 |
+
--ts-violet-2-rgb: #3423A6;
|
| 7 |
+
--ts-violet-3-rgb: #2E1760;
|
| 8 |
+
--ts-violet-4-rgb: #200F4F;
|
| 9 |
+
--ts-violet-5-rgb: #000000; /* TODO FIXME */
|
| 10 |
+
--ts-violet-dark-1-rgb: #b6adfd;
|
| 11 |
+
--ts-violet-dark-2-rgb: #c1bbfd;
|
| 12 |
+
--ts-gray-rgb: #666666;
|
| 13 |
+
--ts-white-rgb: #FFFFFF;
|
| 14 |
+
--ts-light-rgb: #F2F2F2;
|
| 15 |
+
--ts-gray-border: #c5c5c5;
|
| 16 |
+
|
| 17 |
+
--tf-light-rgb-transparent: rgb(242,242,242,0.5); /* #F2F2F2 = rgb(242,242,242) */
|
| 18 |
+
--bs-body-bg: var(--ts-light-rgb); /* link to html bg */
|
| 19 |
+
--bs-link-color: var(--ts-violet-1-rgb);
|
| 20 |
+
--bs-link-hover-color: var(--ts-violet-2-rgb);
|
| 21 |
+
|
| 22 |
+
--bs-navbar-color: var(--ts-white-rgb);
|
| 23 |
+
--bs-navbar-hover-color: var(--ts-white-rgb);
|
| 24 |
+
--bs-nav-link-font-size: 18px;
|
| 25 |
+
--bs-nav-link-font-weight: 400;
|
| 26 |
+
--bs-nav-link-color: var(--ts-white-rgb);
|
| 27 |
+
--ts-nav-link-hover-border-color: var(--ts-violet-1-rgb);
|
| 28 |
+
}
|
| 29 |
+
.btn {
|
| 30 |
+
--bs-btn-border-radius: 1.5rem;
|
| 31 |
+
}
|
| 32 |
+
.btn-primary {
|
| 33 |
+
--bs-btn-bg: var(--ts-violet-1-rgb);
|
| 34 |
+
--bs-btn-border-color: var(--ts-violet-1-rgb);
|
| 35 |
+
--bs-btn-hover-bg: var(--ts-violet-2-rgb);
|
| 36 |
+
--bs-btn-hover-border-color: var(--ts-violet-2-rgb);
|
| 37 |
+
--bs-btn-active-bg: var(--ts-violet-2-rgb);
|
| 38 |
+
--bs-btn-active-border-bg: var(--ts-violet-2-rgb);
|
| 39 |
+
--bs-btn-disabled-bg: var(--ts-violet-1-rgb);
|
| 40 |
+
--bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
|
| 41 |
+
}
|
| 42 |
+
.btn-outline-primary {
|
| 43 |
+
--bs-btn-color: var(--ts-violet-1-rgb);
|
| 44 |
+
--bs-btn-border-color: var(--ts-violet-1-rgb);
|
| 45 |
+
--bs-btn-hover-bg: var(--ts-violet-1-rgb);
|
| 46 |
+
--bs-btn-hover-border-color: var(--ts-violet-1-rgb);
|
| 47 |
+
--bs-btn-active-bg: var(--ts-violet-1-rgb);
|
| 48 |
+
--bs-btn-active-border-color: var(--ts-violet-1-rgb);
|
| 49 |
+
--bs-btn-disabled-color: var(--ts-violet-1-rgb);
|
| 50 |
+
--bs-btn-disabled-border-color: var(--ts-violet-1-rgb);
|
| 51 |
+
}
|
| 52 |
+
.navbar-dark {
|
| 53 |
+
--bs-link-color: var(--ts-violet-dark-1-rgb);
|
| 54 |
+
--bs-link-hover-color: var(--ts-violet-dark-2-rgb);
|
| 55 |
+
--bs-navbar-color: var(--ts-white-rgb);
|
| 56 |
+
--bs-navbar-hover-color: var(--ts-white-rgb);
|
| 57 |
+
}
|
| 58 |
+
.nav-pills {
|
| 59 |
+
--bs-nav-pills-link-active-bg: var(--ts-violet-1-rgb);
|
| 60 |
+
}
|
static/webjars/timefold/img/timefold-favicon.svg
ADDED
|
|
static/webjars/timefold/img/timefold-logo-horizontal-negative.svg
ADDED
|
|
static/webjars/timefold/img/timefold-logo-horizontal-positive.svg
ADDED
|
|
static/webjars/timefold/img/timefold-logo-stacked-positive.svg
ADDED
|
|
static/webjars/timefold/js/timefold-webui.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function replaceTimefoldAutoHeaderFooter() {
|
| 2 |
+
const timefoldHeader = $("header#timefold-auto-header");
|
| 3 |
+
if (timefoldHeader != null) {
|
| 4 |
+
timefoldHeader.addClass("bg-black")
|
| 5 |
+
timefoldHeader.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://timefold.ai">
|
| 9 |
+
<img src="/timefold/img/timefold-logo-horizontal-negative.svg" alt="Timefold logo" width="200">
|
| 10 |
+
</a>
|
| 11 |
+
</nav>
|
| 12 |
+
</div>`));
|
| 13 |
+
}
|
| 14 |
+
const timefoldFooter = $("footer#timefold-auto-footer");
|
| 15 |
+
if (timefoldFooter != null) {
|
| 16 |
+
timefoldFooter.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://timefold.ai">Timefold</a></div>
|
| 21 |
+
<div class="vr"></div>
|
| 22 |
+
<div><a class="text-white" href="https://timefold.ai/docs">Documentation</a></div>
|
| 23 |
+
<div class="vr"></div>
|
| 24 |
+
<div><a class="text-white" href="https://github.com/TimefoldAI/timefold-solver-python">Code</a></div>
|
| 25 |
+
<div class="vr"></div>
|
| 26 |
+
<div class="me-auto"><a class="text-white" href="https://timefold.ai/product/support/">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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Test package initialization
|
tests/test_constraints.py
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from solverforge_legacy.solver.test import ConstraintVerifier
|
| 2 |
+
|
| 3 |
+
from meeting_scheduling.domain import (
|
| 4 |
+
Meeting,
|
| 5 |
+
MeetingAssignment,
|
| 6 |
+
MeetingSchedule,
|
| 7 |
+
Person,
|
| 8 |
+
PreferredAttendance,
|
| 9 |
+
RequiredAttendance,
|
| 10 |
+
Room,
|
| 11 |
+
TimeGrain,
|
| 12 |
+
)
|
| 13 |
+
from meeting_scheduling.constraints import (
|
| 14 |
+
define_constraints,
|
| 15 |
+
room_conflict,
|
| 16 |
+
avoid_overtime,
|
| 17 |
+
required_attendance_conflict,
|
| 18 |
+
required_room_capacity,
|
| 19 |
+
start_and_end_on_same_day,
|
| 20 |
+
required_and_preferred_attendance_conflict,
|
| 21 |
+
preferred_attendance_conflict,
|
| 22 |
+
room_stability,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
DEFAULT_TIME_GRAINS = [
|
| 27 |
+
TimeGrain(
|
| 28 |
+
id=str(i + 1), grain_index=i, day_of_year=1, starting_minute_of_day=480 + i * 15
|
| 29 |
+
)
|
| 30 |
+
for i in range(8)
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
DEFAULT_ROOM = Room(id="1", name="Room 1", capacity=10)
|
| 34 |
+
SMALL_ROOM = Room(id="2", name="Small Room", capacity=1)
|
| 35 |
+
LARGE_ROOM = Room(id="3", name="Large Room", capacity=2)
|
| 36 |
+
ROOM_A = Room(id="4", name="Room A", capacity=10)
|
| 37 |
+
ROOM_B = Room(id="5", name="Room B", capacity=10)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
constraint_verifier = ConstraintVerifier.build(
|
| 41 |
+
define_constraints, MeetingSchedule, MeetingAssignment
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_room_conflict_unpenalized():
|
| 46 |
+
"""Test that no penalty is applied when meetings in the same room do not overlap."""
|
| 47 |
+
meeting1 = create_meeting(1)
|
| 48 |
+
left_assignment = create_meeting_assignment(
|
| 49 |
+
0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
meeting2 = create_meeting(2)
|
| 53 |
+
right_assignment = create_meeting_assignment(
|
| 54 |
+
1, meeting2, DEFAULT_TIME_GRAINS[4], DEFAULT_ROOM
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
constraint_verifier.verify_that(room_conflict).given(
|
| 58 |
+
left_assignment, right_assignment
|
| 59 |
+
).penalizes(0)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_room_conflict_penalized():
|
| 63 |
+
"""Test that a penalty is applied when meetings in the same room overlap."""
|
| 64 |
+
meeting1 = create_meeting(1)
|
| 65 |
+
left_assignment = create_meeting_assignment(
|
| 66 |
+
0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
meeting2 = create_meeting(2)
|
| 70 |
+
right_assignment = create_meeting_assignment(
|
| 71 |
+
1, meeting2, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
constraint_verifier.verify_that(room_conflict).given(
|
| 75 |
+
left_assignment, right_assignment
|
| 76 |
+
).penalizes_by(2)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_avoid_overtime_unpenalized():
|
| 80 |
+
"""Test that no penalty is applied when a meeting fits within available time grains (no overtime)."""
|
| 81 |
+
meeting = create_meeting(1)
|
| 82 |
+
meeting_assignment = create_meeting_assignment(
|
| 83 |
+
0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
constraint_verifier.verify_that(avoid_overtime).given(
|
| 87 |
+
meeting_assignment, *DEFAULT_TIME_GRAINS
|
| 88 |
+
).penalizes(0)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_avoid_overtime_penalized():
|
| 92 |
+
"""Test that a penalty is applied when a meeting exceeds available time grains (overtime)."""
|
| 93 |
+
meeting = create_meeting(1)
|
| 94 |
+
meeting_assignment = create_meeting_assignment(
|
| 95 |
+
0, meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
constraint_verifier.verify_that(avoid_overtime).given(
|
| 99 |
+
meeting_assignment
|
| 100 |
+
).penalizes_by(3)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_required_attendance_conflict_unpenalized():
|
| 104 |
+
"""Test that no penalty is applied when a person does not have overlapping required meetings."""
|
| 105 |
+
person = create_person(1)
|
| 106 |
+
|
| 107 |
+
left_meeting = create_meeting(1, duration=2)
|
| 108 |
+
required_attendance1 = create_required_attendance(0, person, left_meeting)
|
| 109 |
+
|
| 110 |
+
right_meeting = create_meeting(2, duration=2)
|
| 111 |
+
required_attendance2 = create_required_attendance(1, person, right_meeting)
|
| 112 |
+
|
| 113 |
+
left_assignment = create_meeting_assignment(
|
| 114 |
+
0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
|
| 115 |
+
)
|
| 116 |
+
right_assignment = create_meeting_assignment(
|
| 117 |
+
1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
constraint_verifier.verify_that(required_attendance_conflict).given(
|
| 121 |
+
required_attendance1, required_attendance2, left_assignment, right_assignment
|
| 122 |
+
).penalizes(0)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def test_required_attendance_conflict_penalized():
|
| 126 |
+
"""Test that a penalty is applied when a person has overlapping required meetings."""
|
| 127 |
+
person = create_person(1)
|
| 128 |
+
|
| 129 |
+
left_meeting = create_meeting(1, duration=2)
|
| 130 |
+
required_attendance1 = create_required_attendance(0, person, left_meeting)
|
| 131 |
+
|
| 132 |
+
right_meeting = create_meeting(2, duration=2)
|
| 133 |
+
required_attendance2 = create_required_attendance(1, person, right_meeting)
|
| 134 |
+
|
| 135 |
+
left_assignment = create_meeting_assignment(
|
| 136 |
+
0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
|
| 137 |
+
)
|
| 138 |
+
right_assignment = create_meeting_assignment(
|
| 139 |
+
1, right_meeting, DEFAULT_TIME_GRAINS[1], DEFAULT_ROOM
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
constraint_verifier.verify_that(required_attendance_conflict).given(
|
| 143 |
+
required_attendance1, required_attendance2, left_assignment, right_assignment
|
| 144 |
+
).penalizes_by(1)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_required_room_capacity_unpenalized():
|
| 148 |
+
"""Test that no penalty is applied when the room has enough capacity for all required and preferred attendees."""
|
| 149 |
+
person1 = create_person(1)
|
| 150 |
+
person2 = create_person(2)
|
| 151 |
+
|
| 152 |
+
meeting = create_meeting(1, duration=2)
|
| 153 |
+
create_required_attendance(0, person1, meeting)
|
| 154 |
+
create_preferred_attendance(1, person2, meeting)
|
| 155 |
+
|
| 156 |
+
meeting_assignment = create_meeting_assignment(
|
| 157 |
+
0, meeting, DEFAULT_TIME_GRAINS[0], LARGE_ROOM
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
constraint_verifier.verify_that(required_room_capacity).given(
|
| 161 |
+
meeting_assignment
|
| 162 |
+
).penalizes(0)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_required_room_capacity_penalized():
|
| 166 |
+
"""Test that a penalty is applied when the room does not have enough capacity for all required and preferred attendees."""
|
| 167 |
+
person1 = create_person(1)
|
| 168 |
+
person2 = create_person(2)
|
| 169 |
+
|
| 170 |
+
meeting = create_meeting(1, duration=2)
|
| 171 |
+
create_required_attendance(0, person1, meeting)
|
| 172 |
+
create_preferred_attendance(1, person2, meeting)
|
| 173 |
+
|
| 174 |
+
meeting_assignment = create_meeting_assignment(
|
| 175 |
+
0, meeting, DEFAULT_TIME_GRAINS[0], SMALL_ROOM
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
constraint_verifier.verify_that(required_room_capacity).given(
|
| 179 |
+
meeting_assignment
|
| 180 |
+
).penalizes_by(1)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def test_start_and_end_on_same_day_unpenalized():
|
| 184 |
+
"""Test that no penalty is applied when a meeting starts and ends on the same day."""
|
| 185 |
+
# Need custom time grains with day_of_year=0 (DEFAULT_TIME_GRAINS use day_of_year=1)
|
| 186 |
+
start_time_grain = TimeGrain(
|
| 187 |
+
id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480
|
| 188 |
+
)
|
| 189 |
+
end_time_grain = TimeGrain(
|
| 190 |
+
id="2", grain_index=3, day_of_year=0, starting_minute_of_day=525
|
| 191 |
+
) # Same day
|
| 192 |
+
|
| 193 |
+
meeting = create_meeting(1)
|
| 194 |
+
meeting_assignment = create_meeting_assignment(
|
| 195 |
+
0, meeting, start_time_grain, DEFAULT_ROOM
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
constraint_verifier.verify_that(start_and_end_on_same_day).given(
|
| 199 |
+
meeting_assignment, end_time_grain
|
| 200 |
+
).penalizes(0)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def test_start_and_end_on_same_day_penalized():
|
| 204 |
+
"""Test that a penalty is applied when a meeting starts and ends on different days."""
|
| 205 |
+
# Need custom time grains to test different days (start=day 0, end=day 1)
|
| 206 |
+
start_time_grain = TimeGrain(
|
| 207 |
+
id="1", grain_index=0, day_of_year=0, starting_minute_of_day=480
|
| 208 |
+
)
|
| 209 |
+
end_time_grain = TimeGrain(
|
| 210 |
+
id="2", grain_index=3, day_of_year=1, starting_minute_of_day=525
|
| 211 |
+
) # Different day
|
| 212 |
+
|
| 213 |
+
meeting = create_meeting(1)
|
| 214 |
+
meeting_assignment = create_meeting_assignment(
|
| 215 |
+
0, meeting, start_time_grain, DEFAULT_ROOM
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
constraint_verifier.verify_that(start_and_end_on_same_day).given(
|
| 219 |
+
meeting_assignment, end_time_grain
|
| 220 |
+
).penalizes_by(1)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def test_multiple_constraint_violations():
|
| 224 |
+
"""Test that multiple constraints can be violated simultaneously."""
|
| 225 |
+
person = create_person(1)
|
| 226 |
+
|
| 227 |
+
left_meeting = create_meeting(1)
|
| 228 |
+
required_attendance1 = create_required_attendance(0, person, left_meeting)
|
| 229 |
+
left_assignment = create_meeting_assignment(
|
| 230 |
+
0, left_meeting, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
right_meeting = create_meeting(2)
|
| 234 |
+
required_attendance2 = create_required_attendance(1, person, right_meeting)
|
| 235 |
+
right_assignment = create_meeting_assignment(
|
| 236 |
+
1, right_meeting, DEFAULT_TIME_GRAINS[2], DEFAULT_ROOM
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
constraint_verifier.verify_that(room_conflict).given(
|
| 240 |
+
left_assignment, right_assignment
|
| 241 |
+
).penalizes_by(2)
|
| 242 |
+
constraint_verifier.verify_that(required_attendance_conflict).given(
|
| 243 |
+
required_attendance1, required_attendance2, left_assignment, right_assignment
|
| 244 |
+
).penalizes_by(2)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
### Helper functions ###
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def create_meeting(id, topic="Meeting", duration=4):
|
| 251 |
+
"""Helper to create a meeting with standard parameters."""
|
| 252 |
+
return Meeting(id=str(id), topic=f"{topic} {id}", duration_in_grains=duration)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def create_meeting_assignment(id, meeting, time_grain, room):
|
| 256 |
+
"""Helper to create a meeting assignment."""
|
| 257 |
+
return MeetingAssignment(
|
| 258 |
+
id=str(id), meeting=meeting, starting_time_grain=time_grain, room=room
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def create_person(id):
|
| 263 |
+
"""Helper to create a person."""
|
| 264 |
+
return Person(id=str(id), full_name=f"Person {id}")
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def create_required_attendance(id, person, meeting):
|
| 268 |
+
"""Helper to create and link required attendance."""
|
| 269 |
+
attendance = RequiredAttendance(id=str(id), person=person, meeting_id=meeting.id)
|
| 270 |
+
meeting.required_attendances = [attendance]
|
| 271 |
+
return attendance
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def create_preferred_attendance(id, person, meeting):
|
| 275 |
+
"""Helper to create and link preferred attendance."""
|
| 276 |
+
attendance = PreferredAttendance(id=str(id), person=person, meeting_id=meeting.id)
|
| 277 |
+
meeting.preferred_attendances = [attendance]
|
| 278 |
+
return attendance
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
# ========================================
|
| 282 |
+
# Required and Preferred Attendance Conflict Tests
|
| 283 |
+
# ========================================
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def test_required_and_preferred_attendance_conflict_unpenalized():
|
| 287 |
+
"""Test no penalty when required and preferred meetings don't overlap."""
|
| 288 |
+
person = create_person(1)
|
| 289 |
+
|
| 290 |
+
# Meeting 1: grain 0-3 (duration=4), person required
|
| 291 |
+
meeting1 = create_meeting(1, duration=4)
|
| 292 |
+
attendance1 = create_required_attendance(0, person, meeting1)
|
| 293 |
+
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
|
| 294 |
+
|
| 295 |
+
# Meeting 2: grain 4-7 (duration=4), person preferred
|
| 296 |
+
meeting2 = create_meeting(2, duration=4)
|
| 297 |
+
attendance2 = create_preferred_attendance(1, person, meeting2)
|
| 298 |
+
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[4], ROOM_A)
|
| 299 |
+
|
| 300 |
+
constraint_verifier.verify_that(required_and_preferred_attendance_conflict).given(
|
| 301 |
+
attendance1, attendance2, assignment1, assignment2
|
| 302 |
+
).penalizes_by(0)
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def test_required_and_preferred_attendance_conflict_penalized():
|
| 306 |
+
"""Test penalty when person required at one meeting and preferred at overlapping meeting."""
|
| 307 |
+
person = create_person(1)
|
| 308 |
+
|
| 309 |
+
# Meeting 1: grain 0-3 (duration=4), person required
|
| 310 |
+
meeting1 = create_meeting(1, duration=4)
|
| 311 |
+
attendance1 = create_required_attendance(0, person, meeting1)
|
| 312 |
+
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
|
| 313 |
+
|
| 314 |
+
# Meeting 2: grain 2-5 (duration=4), person preferred, overlaps grains 2-3 (2 grains)
|
| 315 |
+
meeting2 = create_meeting(2, duration=4)
|
| 316 |
+
attendance2 = create_preferred_attendance(1, person, meeting2)
|
| 317 |
+
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[2], ROOM_A)
|
| 318 |
+
|
| 319 |
+
# Overlap: grains 2-3 = 2 grains
|
| 320 |
+
constraint_verifier.verify_that(required_and_preferred_attendance_conflict).given(
|
| 321 |
+
attendance1, attendance2, assignment1, assignment2
|
| 322 |
+
).penalizes_by(2)
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# ========================================
|
| 326 |
+
# Preferred Attendance Conflict Tests
|
| 327 |
+
# ========================================
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
def test_preferred_attendance_conflict_unpenalized():
|
| 331 |
+
"""Test no penalty when preferred attendee has non-overlapping meetings."""
|
| 332 |
+
person = create_person(1)
|
| 333 |
+
|
| 334 |
+
# Meeting 1: grain 0-3 (duration=4), person preferred
|
| 335 |
+
meeting1 = create_meeting(1, duration=4)
|
| 336 |
+
attendance1 = create_preferred_attendance(0, person, meeting1)
|
| 337 |
+
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
|
| 338 |
+
|
| 339 |
+
# Meeting 2: grain 4-7 (duration=4), person preferred
|
| 340 |
+
meeting2 = create_meeting(2, duration=4)
|
| 341 |
+
attendance2 = create_preferred_attendance(1, person, meeting2)
|
| 342 |
+
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[4], ROOM_A)
|
| 343 |
+
|
| 344 |
+
constraint_verifier.verify_that(preferred_attendance_conflict).given(
|
| 345 |
+
attendance1, attendance2, assignment1, assignment2
|
| 346 |
+
).penalizes_by(0)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
def test_preferred_attendance_conflict_penalized():
|
| 350 |
+
"""Test penalty when person preferred at multiple overlapping meetings."""
|
| 351 |
+
person = create_person(1)
|
| 352 |
+
|
| 353 |
+
# Meeting 1: grain 0-3 (duration=4), person preferred
|
| 354 |
+
meeting1 = create_meeting(1, duration=4)
|
| 355 |
+
attendance1 = create_preferred_attendance(0, person, meeting1)
|
| 356 |
+
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], DEFAULT_ROOM)
|
| 357 |
+
|
| 358 |
+
# Meeting 2: grain 1-4 (duration=4), person preferred, overlaps grains 1-3 (3 grains)
|
| 359 |
+
meeting2 = create_meeting(2, duration=4)
|
| 360 |
+
attendance2 = create_preferred_attendance(1, person, meeting2)
|
| 361 |
+
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[1], ROOM_A)
|
| 362 |
+
|
| 363 |
+
# Overlap: grains 1-3 = 3 grains
|
| 364 |
+
constraint_verifier.verify_that(preferred_attendance_conflict).given(
|
| 365 |
+
attendance1, attendance2, assignment1, assignment2
|
| 366 |
+
).penalizes_by(3)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
# ========================================
|
| 370 |
+
# Room Stability Tests
|
| 371 |
+
# ========================================
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def test_room_stability_same_room_no_penalty():
|
| 375 |
+
"""
|
| 376 |
+
Test that no penalty is applied when a person attends consecutive
|
| 377 |
+
meetings in the same room (stability is maintained).
|
| 378 |
+
"""
|
| 379 |
+
person = create_person(1)
|
| 380 |
+
|
| 381 |
+
# Meeting 1: time grain 0-1 (duration=2) in ROOM_A
|
| 382 |
+
meeting1 = create_meeting(1, duration=2)
|
| 383 |
+
attendance1 = create_required_attendance(0, person, meeting1)
|
| 384 |
+
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)
|
| 385 |
+
|
| 386 |
+
# Meeting 2: time grain 3-4 (duration=2) in ROOM_A (same room, gap of 1)
|
| 387 |
+
meeting2 = create_meeting(2, duration=2)
|
| 388 |
+
attendance2 = create_required_attendance(1, person, meeting2)
|
| 389 |
+
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[3], ROOM_A)
|
| 390 |
+
|
| 391 |
+
# Same room should not penalize
|
| 392 |
+
constraint_verifier.verify_that(room_stability).given(
|
| 393 |
+
attendance1, attendance2, assignment1, assignment2
|
| 394 |
+
).penalizes(0)
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def test_room_stability_different_room_with_required_attendance():
|
| 398 |
+
"""
|
| 399 |
+
Test that a penalty is applied when a person with required attendance
|
| 400 |
+
has to change rooms between closely scheduled meetings.
|
| 401 |
+
Weighted penalty: back-to-back switches cost more than switches with gaps.
|
| 402 |
+
"""
|
| 403 |
+
person = create_person(1)
|
| 404 |
+
|
| 405 |
+
# Meeting 1: time grain 0-1 (duration=2) in ROOM_A
|
| 406 |
+
left_grain_index = 0
|
| 407 |
+
left_duration = 2
|
| 408 |
+
meeting1 = create_meeting(1, duration=left_duration)
|
| 409 |
+
attendance1 = create_required_attendance(0, person, meeting1)
|
| 410 |
+
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
|
| 411 |
+
|
| 412 |
+
# Meeting 2: time grain 3-4 (duration=2) in ROOM_B (different room)
|
| 413 |
+
right_grain_index = 3
|
| 414 |
+
meeting2 = create_meeting(2, duration=2)
|
| 415 |
+
attendance2 = create_required_attendance(1, person, meeting2)
|
| 416 |
+
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
|
| 417 |
+
|
| 418 |
+
# Weighted penalty: 3 - gap, where gap = right_grain - left_duration - left_grain
|
| 419 |
+
gap = right_grain_index - left_duration - left_grain_index
|
| 420 |
+
expected_penalty = 3 - gap
|
| 421 |
+
|
| 422 |
+
constraint_verifier.verify_that(room_stability).given(
|
| 423 |
+
attendance1, attendance2, assignment1, assignment2
|
| 424 |
+
).penalizes_by(expected_penalty)
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
def test_room_stability_different_room_with_preferred_attendance():
|
| 428 |
+
"""
|
| 429 |
+
Test that a penalty is applied when a person with preferred attendance
|
| 430 |
+
has to change rooms between closely scheduled meetings.
|
| 431 |
+
Weighted penalty applies to preferred attendance too.
|
| 432 |
+
"""
|
| 433 |
+
person = create_person(1)
|
| 434 |
+
|
| 435 |
+
# Meeting 1: time grain 0-1 (duration=2) in ROOM_A
|
| 436 |
+
left_grain_index = 0
|
| 437 |
+
left_duration = 2
|
| 438 |
+
meeting1 = create_meeting(1, duration=left_duration)
|
| 439 |
+
attendance1 = create_preferred_attendance(0, person, meeting1)
|
| 440 |
+
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
|
| 441 |
+
|
| 442 |
+
# Meeting 2: time grain 3-4 (duration=2) in ROOM_B (different room)
|
| 443 |
+
right_grain_index = 3
|
| 444 |
+
meeting2 = create_meeting(2, duration=2)
|
| 445 |
+
attendance2 = create_preferred_attendance(1, person, meeting2)
|
| 446 |
+
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
|
| 447 |
+
|
| 448 |
+
# Weighted penalty: 3 - gap
|
| 449 |
+
gap = right_grain_index - left_duration - left_grain_index
|
| 450 |
+
expected_penalty = 3 - gap
|
| 451 |
+
|
| 452 |
+
constraint_verifier.verify_that(room_stability).given(
|
| 453 |
+
attendance1, attendance2, assignment1, assignment2
|
| 454 |
+
).penalizes_by(expected_penalty)
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def test_room_stability_mixed_attendance_types():
|
| 458 |
+
"""
|
| 459 |
+
Test that room stability penalty applies when mixing required and preferred
|
| 460 |
+
attendance types for the same person.
|
| 461 |
+
"""
|
| 462 |
+
person = create_person(1)
|
| 463 |
+
|
| 464 |
+
# Meeting 1 with required attendance
|
| 465 |
+
left_grain_index = 0
|
| 466 |
+
left_duration = 2
|
| 467 |
+
meeting1 = create_meeting(1, duration=left_duration)
|
| 468 |
+
required_attendance = create_required_attendance(0, person, meeting1)
|
| 469 |
+
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
|
| 470 |
+
|
| 471 |
+
# Meeting 2 with preferred attendance
|
| 472 |
+
right_grain_index = 3
|
| 473 |
+
meeting2 = create_meeting(2, duration=2)
|
| 474 |
+
preferred_attendance = create_preferred_attendance(1, person, meeting2)
|
| 475 |
+
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
|
| 476 |
+
|
| 477 |
+
# Weighted penalty: 3 - gap
|
| 478 |
+
gap = right_grain_index - left_duration - left_grain_index
|
| 479 |
+
expected_penalty = 3 - gap
|
| 480 |
+
|
| 481 |
+
constraint_verifier.verify_that(room_stability).given(
|
| 482 |
+
required_attendance, preferred_attendance, assignment1, assignment2
|
| 483 |
+
).penalizes_by(expected_penalty)
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def test_room_stability_far_apart_meetings_no_penalty():
|
| 487 |
+
"""
|
| 488 |
+
Test that no penalty is applied when meetings are far apart in time,
|
| 489 |
+
even if they're in different rooms.
|
| 490 |
+
"""
|
| 491 |
+
person = create_person(1)
|
| 492 |
+
|
| 493 |
+
# Meeting 1: time grain 0-1 (duration=2) in ROOM_A
|
| 494 |
+
meeting1 = create_meeting(1, duration=2)
|
| 495 |
+
attendance1 = create_required_attendance(0, person, meeting1)
|
| 496 |
+
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)
|
| 497 |
+
|
| 498 |
+
# Meeting 2: time grain 6-7 (duration=2) in ROOM_B
|
| 499 |
+
# gap = grain_index(6) - duration_in_grains(2) - grain_index(0) = 6 - 2 - 0 = 4 > 2
|
| 500 |
+
meeting2 = create_meeting(2, duration=2)
|
| 501 |
+
attendance2 = create_required_attendance(1, person, meeting2)
|
| 502 |
+
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[6], ROOM_B)
|
| 503 |
+
|
| 504 |
+
# Far apart meetings should not penalize even with room change
|
| 505 |
+
constraint_verifier.verify_that(room_stability).given(
|
| 506 |
+
attendance1, attendance2, assignment1, assignment2
|
| 507 |
+
).penalizes(0)
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
def test_room_stability_different_people_no_penalty():
|
| 511 |
+
"""
|
| 512 |
+
Test that no penalty is applied when different people have meetings
|
| 513 |
+
in different rooms (room stability is per-person).
|
| 514 |
+
"""
|
| 515 |
+
person1 = create_person(1)
|
| 516 |
+
person2 = create_person(2)
|
| 517 |
+
|
| 518 |
+
# Person 1's meeting in ROOM_A
|
| 519 |
+
meeting1 = create_meeting(1, duration=2)
|
| 520 |
+
attendance1 = create_required_attendance(0, person1, meeting1)
|
| 521 |
+
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[0], ROOM_A)
|
| 522 |
+
|
| 523 |
+
# Person 2's meeting in ROOM_B (different person, should not affect stability)
|
| 524 |
+
meeting2 = create_meeting(2, duration=2)
|
| 525 |
+
attendance2 = create_required_attendance(1, person2, meeting2)
|
| 526 |
+
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[3], ROOM_B)
|
| 527 |
+
|
| 528 |
+
# Different people should not trigger room stability penalty
|
| 529 |
+
constraint_verifier.verify_that(room_stability).given(
|
| 530 |
+
attendance1, attendance2, assignment1, assignment2
|
| 531 |
+
).penalizes(0)
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
def test_room_stability_back_to_back_highest_penalty():
|
| 535 |
+
"""
|
| 536 |
+
Test that back-to-back room switches (gap=0) incur the highest penalty (3).
|
| 537 |
+
This verifies the weighted penalty gradient: closer switches cost more.
|
| 538 |
+
"""
|
| 539 |
+
person = create_person(1)
|
| 540 |
+
|
| 541 |
+
# Meeting 1: grain 0-1 (duration=2) in ROOM_A
|
| 542 |
+
left_grain_index = 0
|
| 543 |
+
left_duration = 2
|
| 544 |
+
meeting1 = create_meeting(1, duration=left_duration)
|
| 545 |
+
attendance1 = create_required_attendance(0, person, meeting1)
|
| 546 |
+
assignment1 = create_meeting_assignment(0, meeting1, DEFAULT_TIME_GRAINS[left_grain_index], ROOM_A)
|
| 547 |
+
|
| 548 |
+
# Meeting 2: grain 2-3 (immediately after) in ROOM_B
|
| 549 |
+
right_grain_index = 2 # Starts right after meeting1 ends
|
| 550 |
+
meeting2 = create_meeting(2, duration=2)
|
| 551 |
+
attendance2 = create_required_attendance(1, person, meeting2)
|
| 552 |
+
assignment2 = create_meeting_assignment(1, meeting2, DEFAULT_TIME_GRAINS[right_grain_index], ROOM_B)
|
| 553 |
+
|
| 554 |
+
# gap = 2 - 2 - 0 = 0, penalty = 3 - 0 = 3
|
| 555 |
+
gap = right_grain_index - left_duration - left_grain_index
|
| 556 |
+
expected_penalty = 3 - gap
|
| 557 |
+
assert gap == 0, f"Test setup error: expected gap=0, got {gap}"
|
| 558 |
+
assert expected_penalty == 3, f"Test setup error: expected penalty=3, got {expected_penalty}"
|
| 559 |
+
|
| 560 |
+
constraint_verifier.verify_that(room_stability).given(
|
| 561 |
+
attendance1, attendance2, assignment1, assignment2
|
| 562 |
+
).penalizes_by(expected_penalty)
|
tests/test_feasible.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from meeting_scheduling.rest_api import app
|
| 2 |
+
from meeting_scheduling.converters import MeetingScheduleModel, model_to_schedule
|
| 3 |
+
|
| 4 |
+
from fastapi.testclient import TestClient
|
| 5 |
+
from time import sleep
|
| 6 |
+
from pytest import fail
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
client = TestClient(app)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def json_to_meeting_schedule(schedule_json):
|
| 13 |
+
"""Convert JSON response to MeetingSchedule domain object with proper score."""
|
| 14 |
+
# Parse JSON to Pydantic model first
|
| 15 |
+
schedule_model = MeetingScheduleModel.model_validate(schedule_json)
|
| 16 |
+
|
| 17 |
+
# Convert to domain model
|
| 18 |
+
schedule = model_to_schedule(schedule_model)
|
| 19 |
+
|
| 20 |
+
return schedule
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_feasible():
|
| 24 |
+
demo_data_response = client.get("/demo-data")
|
| 25 |
+
assert demo_data_response.status_code == 200
|
| 26 |
+
|
| 27 |
+
job_id_response = client.post("/schedules", json=demo_data_response.json())
|
| 28 |
+
assert job_id_response.status_code == 200
|
| 29 |
+
job_id = job_id_response.text[1:-1]
|
| 30 |
+
|
| 31 |
+
ATTEMPTS = 1_000
|
| 32 |
+
for _ in range(ATTEMPTS):
|
| 33 |
+
sleep(0.1)
|
| 34 |
+
schedule_response = client.get(f"/schedules/{job_id}")
|
| 35 |
+
schedule_json = schedule_response.json()
|
| 36 |
+
schedule = json_to_meeting_schedule(schedule_json)
|
| 37 |
+
|
| 38 |
+
if schedule.score is not None and schedule.score.is_feasible:
|
| 39 |
+
# Additional validation like Java version
|
| 40 |
+
assert all(
|
| 41 |
+
assignment.starting_time_grain is not None
|
| 42 |
+
and assignment.room is not None
|
| 43 |
+
for assignment in schedule.meeting_assignments
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
stop_solving_response = client.delete(f"/schedules/{job_id}")
|
| 47 |
+
assert stop_solving_response.status_code == 200
|
| 48 |
+
return
|
| 49 |
+
|
| 50 |
+
client.delete(f"/schedules/{job_id}")
|
| 51 |
+
fail("solution is not feasible")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_analyze():
|
| 55 |
+
demo_data_response = client.get("/demo-data")
|
| 56 |
+
assert demo_data_response.status_code == 200
|
| 57 |
+
|
| 58 |
+
job_id_response = client.post("/schedules", json=demo_data_response.json())
|
| 59 |
+
assert job_id_response.status_code == 200
|
| 60 |
+
job_id = job_id_response.text[1:-1]
|
| 61 |
+
|
| 62 |
+
ATTEMPTS = 1_000
|
| 63 |
+
for _ in range(ATTEMPTS):
|
| 64 |
+
sleep(0.1)
|
| 65 |
+
schedule_response = client.get(f"/schedules/{job_id}")
|
| 66 |
+
schedule_json = schedule_response.json()
|
| 67 |
+
schedule = json_to_meeting_schedule(schedule_json)
|
| 68 |
+
|
| 69 |
+
if schedule.score is not None and schedule.score.is_feasible:
|
| 70 |
+
# Test the analyze endpoint
|
| 71 |
+
analysis_response = client.put("/schedules/analyze", json=schedule_json)
|
| 72 |
+
assert analysis_response.status_code == 200
|
| 73 |
+
analysis = analysis_response.text
|
| 74 |
+
assert analysis is not None
|
| 75 |
+
|
| 76 |
+
# Test with fetchPolicy parameter
|
| 77 |
+
analysis_response_2 = client.put(
|
| 78 |
+
"/schedules/analyze?fetchPolicy=FETCH_SHALLOW", json=schedule_json
|
| 79 |
+
)
|
| 80 |
+
assert analysis_response_2.status_code == 200
|
| 81 |
+
analysis_2 = analysis_response_2.text
|
| 82 |
+
assert analysis_2 is not None
|
| 83 |
+
|
| 84 |
+
client.delete(f"/schedules/{job_id}")
|
| 85 |
+
return
|
| 86 |
+
|
| 87 |
+
client.delete(f"/schedules/{job_id}")
|
| 88 |
+
fail("solution is not feasible for analyze test")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def test_analyze_constraint_scores():
|
| 92 |
+
"""Test that the analyze endpoint returns proper constraint scores instead of all zeros."""
|
| 93 |
+
demo_data_response = client.get("/demo-data")
|
| 94 |
+
assert demo_data_response.status_code == 200
|
| 95 |
+
|
| 96 |
+
job_id_response = client.post("/schedules", json=demo_data_response.json())
|
| 97 |
+
assert job_id_response.status_code == 200
|
| 98 |
+
job_id = job_id_response.text[1:-1]
|
| 99 |
+
|
| 100 |
+
ATTEMPTS = 1_000
|
| 101 |
+
for _ in range(ATTEMPTS):
|
| 102 |
+
sleep(0.1)
|
| 103 |
+
schedule_response = client.get(f"/schedules/{job_id}")
|
| 104 |
+
schedule_json = schedule_response.json()
|
| 105 |
+
schedule = json_to_meeting_schedule(schedule_json)
|
| 106 |
+
|
| 107 |
+
if schedule.score is not None and schedule.score.is_feasible:
|
| 108 |
+
# Test the analyze endpoint and verify constraint scores
|
| 109 |
+
analysis_response = client.put("/schedules/analyze", json=schedule_json)
|
| 110 |
+
assert analysis_response.status_code == 200
|
| 111 |
+
|
| 112 |
+
# Parse the analysis response
|
| 113 |
+
analysis_data = json.loads(analysis_response.text)
|
| 114 |
+
constraints = analysis_data.get("constraints", [])
|
| 115 |
+
|
| 116 |
+
# Verify we have constraints
|
| 117 |
+
assert len(constraints) > 0, "Should have at least one constraint"
|
| 118 |
+
|
| 119 |
+
# Check that at least some constraints have non-zero scores
|
| 120 |
+
# (since we have a feasible solution, some soft constraints should be violated)
|
| 121 |
+
non_zero_scores = 0
|
| 122 |
+
for constraint in constraints:
|
| 123 |
+
score_str = constraint.get("score", "")
|
| 124 |
+
if score_str and score_str != "0hard/0medium/0soft":
|
| 125 |
+
non_zero_scores += 1
|
| 126 |
+
print(
|
| 127 |
+
f"Found non-zero constraint score: {constraint.get('name')} = {score_str}"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# We should have at least some non-zero scores for soft constraints
|
| 131 |
+
assert non_zero_scores > 0, (
|
| 132 |
+
f"Expected some non-zero constraint scores, but all were zero. Total constraints: {len(constraints)}"
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
print(
|
| 136 |
+
f"✅ Analysis test passed: Found {non_zero_scores} constraints with non-zero scores out of {len(constraints)} total constraints"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
client.delete(f"/schedules/{job_id}")
|
| 140 |
+
return
|
| 141 |
+
|
| 142 |
+
client.delete(f"/schedules/{job_id}")
|
| 143 |
+
fail("solution is not feasible for analyze constraint scores test")
|