Compare commits

...

2 commits

Author SHA1 Message Date
Théophile Bastian 05372eef68 Repartition now logs groups' assigned toughness
Closes #5
2023-03-16 11:00:31 +01:00
Théophile Bastian 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 typing as t
import random import random
from pathlib import Path from pathlib import Path
from collections import defaultdict
import logging import logging
import jinja2 as j2 import jinja2 as j2
from .config import Task, Category, Config from .config import Task, Category, Config
from .partition import TaskId, partition from .partition import TaskId, partition
from . import intermed_file
from . import util from . import util
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -178,6 +180,32 @@ def export_latex(config: Config, groupes: list[list[str]]) -> str:
return template.render(**env) 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]: def repartition(config: Config) -> list[Group]:
"""Crée des groupes et assigne des tâches""" """Crée des groupes et assigne des tâches"""
groupes: list[Group] = constituer_groupes(config.choristes) groupes: list[Group] = constituer_groupes(config.choristes)
@ -201,6 +229,16 @@ def main() -> None:
"--to-short-md", "--to-short-md",
help="Exporter vers un fichier Markdown (pour vérification uniquement)", 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( parser.add_argument(
"-g", "-g",
"--debug", "--debug",
@ -218,24 +256,38 @@ def main() -> None:
if args.bare_tasks: if args.bare_tasks:
util.write_to_file(args.bare_tasks, export_bare_tasks_md(config)) util.write_to_file(args.bare_tasks, export_bare_tasks_md(config))
retry: int = 0 groupes: list[Group]
MAX_RETRY: int = 4 if args.use_intermed:
while retry < MAX_RETRY: intermed = intermed_file.IntermedFile.from_file(
try: config.choristes, config.taches, args.use_intermed
groupes = repartition(config) )
break intermed.sanity_check()
except AssignError as exn: groupes = intermed.to_assignment(config)
retry += 1 else:
logger.warning( retry: int = 0
"[essai %d/%d] Échec de répartition des tâches : %s", MAX_RETRY: int = 4
retry, while retry < MAX_RETRY:
MAX_RETRY, try:
exn, groupes = repartition(config)
) break
if retry == MAX_RETRY: except AssignError as exn:
logger.critical("Échec de répartition des tâches.") retry += 1
raise exn from exn 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: if args.to_tex:
util.write_to_file(args.to_tex, export_latex(config, groupes)) util.write_to_file(args.to_tex, export_latex(config, groupes))
if args.to_short_md: 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,
)