grading
Version:
Grading of student submissions, in particular programming tests.
347 lines (290 loc) • 13.3 kB
text/typescript
import { OptionValues } from "commander";
import fs from "fs";
import { readCSV } from "../csv";
import { ensureFileExists, rmDir, zip } from "../fsUtil";
import { readGradingSchema } from "../grade/gradingschema";
import { error, generateFileName, log, retrieveOriginalMoodleFile, verb, verbosity, warn } from "./cliUtil";
import { AllExamResults, calculateConclusions, checkManualConclusion, Conclusion, normalizeAliases, readAllResults, readManualConclusion, retrieveResultCSVFilenames } from "./cmdConclude";
import { prepareLatexFiles, runLatex } from "./cmdPDF";
import { compareNumberAware } from "./compareNumberAware";
export async function cmdSummary(options: OptionValues, runInternally = false) {
verbosity(options);
const resultsDir: string = options.resultsDir;
const encoding: string = options.encoding;
const resultCSVDelimiter: string = options.resultCSVDelimiter;
const selectBest: number = options.selectBest;
const totalExams: number = options.totalExams;
const maxFailed: number = options.maxFailed;
const noFinalGradeBefore: string = options.noFinalGradeBefore;
const thisPartDescription = options.thisPartDescription;
const latex = options.latex;
const schemaFile = options.gradingSchemaFile;
const summariesLatexFragmentFile = options.summariesLatexFragmentFile;
const summaryLatexMainFile = options.summaryLatexMainFile;
const latexMainFolderCopy = options.latexMainFolderCopy;
const pdfSummariesDir = options.pdfSummariesDir;
const workDir = options.workDir;
const dry = options.dry;
const latexConsole = options.latexConsole
const noSummaryPDFZip = !!options.noSummaryPDFZip;
const summaryPDFZipFile = options.summaryPDFZipFile;
const clean = !!options.clean;
let max = Number.parseInt(options.max);
try {
// Some prequisits
const moodleFile = await retrieveOriginalMoodleFile("", options);
await ensureFileExists(moodleFile, "Check MoodleCSV setting.");
const readable = fs.createReadStream(moodleFile, "utf-8");
const moodleGradingTable = await readCSV(readable);
const nameToLatestSubmissionID = new Map<string, string>();
for (let row = 2; row <= moodleGradingTable.rowsCount; row++) { // row 1 is header
const longID = moodleGradingTable.at("ID", row);
if (!longID.startsWith("Teilnehmer/in")) {
throw new Error(`Invalid ID in Moodle file: ${longID}, row ${row}`);
}
const submissionID = longID.slice("Teilnehmer/in".length);
const studentName = moodleGradingTable.at("Vollständiger Name", row);
nameToLatestSubmissionID.set(studentName, submissionID);
}
const gradingSchema = await readGradingSchema(schemaFile);
// Create Conclusion
const allResultCSVs = await retrieveResultCSVFilenames(resultsDir);
if (allResultCSVs.length == 0) {
error(`No result CSV files found in ${resultsDir}`);
return;
}
const manualConclusion = await readManualConclusion(options);
checkManualConclusion(manualConclusion);
const allExamResults = new AllExamResults();
verb('Reading result CSVs...')
await readAllResults(allResultCSVs, resultsDir, encoding, resultCSVDelimiter, allExamResults);
normalizeAliases(manualConclusion, allExamResults);
verb('Calculating conclusions...');
// Collect all submissions
const { conclusions } = calculateConclusions(allExamResults, totalExams, selectBest, maxFailed, noFinalGradeBefore, manualConclusion);
// Write Latex Fragments
await createSummaryLatexFragment(
{
courseInfo: {
date: new Date().toLocaleDateString("de-DE"),
course: gradingSchema.course, term: gradingSchema.term,
totalExams, selectBest, minPassed: totalExams - maxFailed
},
thisPartDescription, conclusions,
nameToLatestSubmissionID, summariesLatexFragmentFile,
dry
}
);
// Create PDFs
if (!dry) {
const { latexMainLatex, latexMainPDF, latexMainWOExt } = prepareLatexFiles(summaryLatexMainFile, workDir, summariesLatexFragmentFile,latexMainFolderCopy);
let count = 0;
for (let row = 2; row <= moodleGradingTable.rowsCount; row++) { // row 1 is header
const longID = moodleGradingTable.at("ID", row);
if (!longID.startsWith("Teilnehmer/in")) {
throw new Error(`Invalid ID in Moodle file: ${longID}, row ${row}`);
}
const submissionID = longID.slice("Teilnehmer/in".length);
const studentName = moodleGradingTable.at("Vollständiger Name", row);
// const studentEmail = moodleGradingTable.at("E-Mail-Adresse", row);
const conclusion = conclusions.find(c => c.name === studentName);
if (conclusion?.finalGrade==="-") {
continue;
}
if (conclusion && conclusion.examGrades.some(eg => compareNumberAware(eg.exam, noFinalGradeBefore) >= 0)) {
count++;
const subDir = `${studentName}_${submissionID}_assignsubmission_file`;
await runLatex(subDir, latexMainLatex, pdfSummariesDir, latex, workDir,
latexMainPDF, latexMainWOExt, latexConsole, options);
if (count >= max && max > 0) {
break;
}
}
}
log(`Created ${count} PDF summaries`)
} else {
log(`Would create PDF summaries, skipped in dry mode.`);
}
if (!noSummaryPDFZip && !dry) {
const fileName = generateFileName(summaryPDFZipFile, { dateTime: Date.now() });
await zip(pdfSummariesDir, fileName);
log(`Created zip file to upload ${fileName}`)
if (clean) {
await rmDir(pdfSummariesDir);
}
}
} catch (err) {
error(err);
program.error(String(err));
}
if (!runInternally) {
log(`${SEP}\nDone`);
}
};
function numberToGrading(grading: number): string {
return grading.toFixed(1).replace('.', ',');
}
function weightToPercentage(weight: number): string {
return (weight * 100).toFixed(0);
}
type CourseInfo = {
date: string;
course: string;
term: string;
totalExams: number;
selectBest: number;
minPassed: number;
}
interface ToSummaryLatexArgs {
courseInfo: CourseInfo;
thisPartDescription: string;
conclusions: Conclusion[];
nameToLatestSubmissionID: Map<string, string>;
}
interface CreateSummaryLatexFragmentArgs extends ToSummaryLatexArgs {
summariesLatexFragmentFile: string;
dry: boolean;
}
async function createSummaryLatexFragment(
{ courseInfo,
thisPartDescription,
conclusions,
nameToLatestSubmissionID,
summariesLatexFragmentFile,
dry }: CreateSummaryLatexFragmentArgs
) {
const latexString = toLatex({
courseInfo,
thisPartDescription,
conclusions,
nameToLatestSubmissionID
});
if (dry) {
log(`Would write latex fragment "${summariesLatexFragmentFile}" (${(latexString.length / 1024).toFixed(2)} kB), skipped in dry mode.`);
return;
} else {
log(`Write latex fragment "${summariesLatexFragmentFile}".`);
await fs.promises.writeFile(summariesLatexFragmentFile, latexString);
}
}
function toLatex({ courseInfo,
thisPartDescription,
conclusions, nameToLatestSubmissionID }: ToSummaryLatexArgs) {
let outstr = "";
let indent = 0;
const def = (name: string, value: any) => {
outstr += `${" ".repeat(indent)}\\def\\${name}{${value}}\n`;
}
const outLn = (...text: any[]) => {
outstr += ind();
outstr += text.map(t => "" + t).map(s => s.split('\n').join("\n" + ind())).join("");
outstr += '\n';
}
const ind = () => {
return "".padStart(indent * 4, " ")
}
const block = (start: string, content: () => void, end: string) => {
outLn(start);
indent++;
content();
indent--;
outLn(end);
}
const examNames = [...new Set(conclusions.map(c => c.examGrades).flat().map(examGrade => examGrade.exam))].sort((a, b) => compareNumberAware(a, b));
if (examNames.length !== courseInfo.totalExams) {
warn(`Number of exams in conclusions does not match total exams: ${examNames.length} vs. ${courseInfo.totalExams}`);
}
const examPrefix = commonPrefix(examNames);
outLn('% LaTeX fragment, generated by grading tool, cf. grading summary');
outLn('% --------------------');
outLn('\\newcommand{\\ifequals}[3]{\\ifthenelse{\\equal{#1}{#2}}{#3}{}}');
outLn('%--------------------------------------');
outLn('% Course data');
outLn('%--------------------------------------');
def("summaryDate", courseInfo.date);
def("summaryCourse", courseInfo.course);
def("summaryTerm", courseInfo.term);
def("summaryTotalExams", courseInfo.totalExams);
def("summarySelectBest", courseInfo.selectBest);
def("summaryMinPassed", courseInfo.minPassed);
def("summaryThisTeilleistung", thisPartDescription);
outLn('%--------------------------------------');
outLn('% Student data');
outLn('%--------------------------------------');
for (const conclusion of conclusions) {
const submissionID = nameToLatestSubmissionID.get(conclusion.name);
if (!submissionID) {
throw new Error(`No submission ID found for ${conclusion.name}`);
}
block(`\\ifequals{\\submissionid}{${submissionID}}{`, () => {
const gradingForThisPart = conclusion.finalGrade;
if (!gradingForThisPart) {
throw new Error(`No grading for ${conclusion.name} for this part (${thisPartDescription}) found`);
}
def("ergName", conclusion.name);
def("ergSuccessful", conclusion.successful);
def("ergGradingThisPart", gradingForThisPart);
block("\\def\\ergConclusion{", () => {
const examColumns = "|c".repeat(examNames.length);
const examGrades = conclusion.examGrades;
block(`\\begin{center}\\begin{tabular}{l${examColumns}}`, () => {
outLn(`\\textbf{${examPrefix}} ${examNames.map(name => " &" + name.slice(examPrefix.length)).join('')} \\\\`);
outLn(`\\textbf{Abgegeben} ${examNames.map(examName => examGrades.some(eg => eg.exam === examName) ? " & \\ding{52}" : " & \\ding{56}").join('')} \\\\ \\hline`);
let countedMissing = 0;
outLn(`\\textbf{Note} ${examNames.map(examName => {
const examGrade = examGrades.find(eg => eg.exam === examName);
if (!examGrade) {
countedMissing++;
if (countedMissing <= conclusion.missing) {
return ` & 5,0`;
} else {
return ` & {\\color{gray}5,0}`;
}
} else {
if (examGrade.selected) {
return ` & ${numberToGrading(examGrade.grade)}`;
} else {
return ` & {\\color{gray}${numberToGrading(examGrade.grade)}}`;
}
}
}).join('')} \\\\ `);
countedMissing = 0;
outLn(`\\textbf{Gezählt} ${examNames.map(examName => {
const examGrade = examGrades.find(eg => eg.exam === examName);
if (!examGrade) {
countedMissing++;
if (countedMissing <= conclusion.missing) {
return ` & \\ding{52}`;
} else {
return ` & -- `;
}
} else {
if (examGrade.selected) {
return ` & \\ding{52}`;
} else {
return ` & --`;
}
}
}).join('')} \\\\ `);
}, `\\end{tabular}\\end{center}`);
}, "}");
}, '}');
}
return outstr
}
function commonPrefix(names: string[]) {
let prefix = names[0];
for (let i = 1; i < names.length; i++) {
const name = names[i];
while (prefix.length > 0) {
if (name.startsWith(prefix)) {
break;
}
prefix = prefix.slice(0, -1);
}
if (prefix.length === 0) {
return "";
}
}
return prefix;
}