Compare commits

...

2 commits

Author SHA1 Message Date
05372eef68 Repartition now logs groups' assigned toughness
Closes #5
2023-03-16 11:00:31 +01:00
44cb69583f Add intermed file
Does not yet display assignment fairness
Contributes towards #5
2023-03-14 17:08:55 +01:00
2 changed files with 236 additions and 17 deletions

View file

@ -2,11 +2,13 @@ import argparse
import typing as t
import random
from pathlib import Path
from collections import defaultdict
import logging
import jinja2 as j2
from .config import Task, Category, Config
from .partition import TaskId, partition
from . import intermed_file
from . import util
logger = logging.getLogger(__name__)
@ -178,6 +180,32 @@ def export_latex(config: Config, groupes: list[list[str]]) -> str:
return template.render(**env)
def log_assignment_toughness(config: Config):
"""Prints each group's assigned tasks toughness"""
grp_tough: dict[int, int] = defaultdict(int)
def explore_tasks(task: Task | Category):
if isinstance(task, Task):
assert task.assigned is not None
for grp in task.assigned:
grp_tough[grp] += task.tough
else:
for child in task.tasks:
explore_tasks(child)
explore_tasks(config.taches)
grp_ids = list(grp_tough.keys())
grp_ids.sort()
tough_lines = []
for grp_id in grp_ids:
tough_lines.append(f"{grp_id+1:2d} : {grp_tough[grp_id]:3d}")
out_str = "Répartition des pénibilités :\n" + "\n".join(tough_lines)
logger.info(out_str)
def repartition(config: Config) -> list[Group]:
"""Crée des groupes et assigne des tâches"""
groupes: list[Group] = constituer_groupes(config.choristes)
@ -201,6 +229,16 @@ def main() -> None:
"--to-short-md",
help="Exporter vers un fichier Markdown (pour vérification uniquement)",
)
parser.add_argument(
"--to-intermed",
type=Path,
help="Exporter vers un fichier éditable manuellement, et finaliser plus tard",
)
parser.add_argument(
"--use-intermed",
type=Path,
help="Importer un fichier éditable manuellement précédemment généré",
)
parser.add_argument(
"-g",
"--debug",
@ -218,24 +256,38 @@ def main() -> None:
if args.bare_tasks:
util.write_to_file(args.bare_tasks, export_bare_tasks_md(config))
retry: int = 0
MAX_RETRY: int = 4
while retry < MAX_RETRY:
try:
groupes = repartition(config)
break
except AssignError as exn:
retry += 1
logger.warning(
"[essai %d/%d] Échec de répartition des tâches : %s",
retry,
MAX_RETRY,
exn,
)
if retry == MAX_RETRY:
logger.critical("Échec de répartition des tâches.")
raise exn from exn
groupes: list[Group]
if args.use_intermed:
intermed = intermed_file.IntermedFile.from_file(
config.choristes, config.taches, args.use_intermed
)
intermed.sanity_check()
groupes = intermed.to_assignment(config)
else:
retry: int = 0
MAX_RETRY: int = 4
while retry < MAX_RETRY:
try:
groupes = repartition(config)
break
except AssignError as exn:
retry += 1
logger.warning(
"[essai %d/%d] Échec de répartition des tâches : %s",
retry,
MAX_RETRY,
exn,
)
if retry == MAX_RETRY:
logger.critical("Échec de répartition des tâches.")
raise exn from exn
log_assignment_toughness(config)
if args.to_intermed:
intermed = intermed_file.IntermedFile.from_assignment(config, groupes)
intermed.sanity_check()
intermed.write(args.to_intermed)
if args.to_tex:
util.write_to_file(args.to_tex, export_latex(config, groupes))
if args.to_short_md:

View file

@ -0,0 +1,167 @@
""" Fichier intermédiaire aisément éditable à la main """
from pathlib import Path
import typing as t
from dataclasses import dataclass
import dataclasses
import ruamel
from . import config
Assignment: t.TypeAlias = dict[str, list[int]]
class BadIntermedFile(Exception):
"""Raised when an intermediary file doesn't contain what is expected"""
@dataclass
class _IntermedFileData:
assignation: Assignment
nb_groupes: int
groupes: list[list[str]]
class IntermedFile:
"""Fichier intermédiaire aisément éditable à la main"""
choristes: list[str]
tasks: config.Category
data: _IntermedFileData
def __init__(
self,
choristes: list[str],
tasks: config.Category,
assignment: dict[str, list[int]],
nb_groups: int,
groups: list[list[str]],
):
self.choristes = choristes
self.tasks = tasks
self.data = _IntermedFileData(
assignation=assignment,
nb_groupes=nb_groups,
groupes=groups,
)
def sanity_check(self):
"""Check that the data is consistent -- does not check that the assignment is
fair"""
def check_taches(task: config.Category | config.Task):
if isinstance(task, config.Task):
if task.qualified_name not in self.data.assignation:
raise BadIntermedFile(
f"Aucun groupe assigné pour {task.qualified_name}"
)
nb_assigned = len(self.data.assignation[task.qualified_name])
if nb_assigned != task.nb_groups:
raise BadIntermedFile(
f"{nb_assigned} groupes assignés pour {task.qualified_name}, "
f"il en faut {task.nb_groups}"
)
else:
for child in task.tasks:
check_taches(child)
if len(self.data.groupes) != self.data.nb_groupes:
raise BadIntermedFile("Nombre de groupes incohérent")
choristes_seen: dict[str, int] = {}
for grp_id, group in enumerate(self.data.groupes):
for choriste in group:
if choriste in choristes_seen:
raise BadIntermedFile(
f"{choriste} est dans plusieurs groupes : "
f"{choristes_seen[choriste]} et {grp_id}."
)
if choriste not in self.choristes:
raise BadIntermedFile(
f"{choriste} est dans le groupe {grp_id}, mais ne fait pas "
"partie de la liste des choristes"
)
choristes_seen[choriste] = grp_id
missing_choristes = set(self.choristes) - set(choristes_seen.keys())
if missing_choristes:
raise BadIntermedFile(
"Ces choristes ne sont dans aucun groupe : "
+ ", ".join(missing_choristes)
)
check_taches(self.tasks)
def write(self, to_path: Path):
"""Write the intermediary file to this path"""
yaml = ruamel.yaml.YAML()
yaml.dump(dataclasses.asdict(self.data), to_path)
def to_assignment(self, conf: config.Config) -> list[list[str]]:
"""Use this intermediary file to make an assignment. Assigns groups to tasks
in the config, and returns the groups"""
def assign_tasks(task: config.Category | config.Task):
if isinstance(task, config.Task):
task.assigned = self.data.assignation[task.qualified_name]
task.assigned.sort()
else:
for child in task.tasks:
assign_tasks(child)
assign_tasks(conf.taches)
return self.data.groupes
@classmethod
def from_assignment(
cls, conf: config.Config, groups: list[list[str]]
) -> "IntermedFile":
def make_assignment(task: config.Category | config.Task, out: Assignment):
if isinstance(task, config.Task):
assert task.qualified_name not in out
assert task.assigned is not None
# Internal groups are 0-indexed
out[task.qualified_name] = list(map(lambda x: x + 1, task.assigned))
else:
for child in task.tasks:
make_assignment(child, out)
assignment: Assignment = {}
make_assignment(conf.taches, assignment)
return cls(
choristes=conf.choristes,
tasks=conf.taches,
assignment=assignment,
nb_groups=len(groups),
groups=groups,
)
@classmethod
def from_file(
cls, choristes: list[str], tasks: config.Category, file: Path
) -> "IntermedFile":
yaml = ruamel.yaml.YAML()
with file.open("r") as handle:
raw_data = yaml.load(handle)
for expected_field in dataclasses.fields(_IntermedFileData):
if expected_field.name not in raw_data:
raise BadIntermedFile(
f"Le champ '{expected_field.name}' est absent du fichier !"
)
try:
data = _IntermedFileData(**raw_data)
except TypeError as exn:
raise BadIntermedFile(
"Impossible d'interpréter ce fichier comme un fichier intermédiaire."
) from exn
# Internal groups are 0-indexed
for grp in data.assignation.values():
for pos in range(len(grp)):
grp[pos] -= 1
return cls(
choristes=choristes,
tasks=tasks,
assignment=data.assignation,
nb_groups=data.nb_groupes,
groups=data.groupes,
)