/** * OLAT - Online Learning and Training<br> * http://www.olat.org * <p> * Licensed under the Apache License, Version 2.0 (the "License"); <br> * you may not use this file except in compliance with the License.<br> * You may obtain a copy of the License at * <p> * http://www.apache.org/licenses/LICENSE-2.0 * <p> * Unless required by applicable law or agreed to in writing,<br> * software distributed under the License is distributed on an "AS IS" BASIS, <br> * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br> * See the License for the specific language governing permissions and <br> * limitations under the License. * <p> * Copyright (c) frentix GmbH<br> * http://www.frentix.com<br> * <p> */ package org.olat.ims.qti.statistics.manager; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.persistence.TypedQuery; import org.olat.core.commons.persistence.DB; import org.olat.ims.qti.QTIResultManager; import org.olat.ims.qti.editor.beecom.objects.FIBResponse; import org.olat.ims.qti.editor.beecom.objects.Item; import org.olat.ims.qti.editor.beecom.objects.Response; import org.olat.ims.qti.statistics.QTIStatisticSearchParams; import org.olat.ims.qti.statistics.QTIStatisticsManager; import org.olat.ims.qti.statistics.model.QTIStatisticResult; import org.olat.ims.qti.statistics.model.QTIStatisticResultSet; import org.olat.ims.qti.statistics.model.StatisticAnswerOption; import org.olat.ims.qti.statistics.model.StatisticAssessment; import org.olat.ims.qti.statistics.model.StatisticChoiceOption; import org.olat.ims.qti.statistics.model.StatisticFIBOption; import org.olat.ims.qti.statistics.model.StatisticItem; import org.olat.ims.qti.statistics.model.StatisticKPrimOption; import org.olat.ims.qti.statistics.model.StatisticSurveyItem; import org.olat.ims.qti.statistics.model.StatisticSurveyItemResponse; import org.olat.ims.qti.statistics.model.StatisticsItem; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; /** * * @author srosse, stephane.rosse@frentix.com, http://www.frentix.com * */ @Service public class QTIStatisticsManagerImpl implements QTIStatisticsManager { @Autowired private DB dbInstance; private StringBuilder decorateRSet(StringBuilder sb, QTIStatisticSearchParams searchParams) { sb.append(" where rset.olatResource=:resourceId and rset.olatResourceDetail=:resSubPath") .append(" and rset.lastModified = (select max(r2set.lastModified) from ").append(QTIStatisticResultSet.class.getName()).append(" r2set") .append(" where r2set.identityKey=rset.identityKey and r2set.olatResource=rset.olatResource and r2set.olatResourceDetail=rset.olatResourceDetail") .append(" )"); if(searchParams.getLimitToGroups() != null && searchParams.getLimitToGroups().size() > 0) { sb.append(" and rset.identityKey in ( select membership.identity.key from bgroupmember membership ") .append(" where membership.group in (:baseGroups)") .append(" )"); } if(searchParams.isMayViewAllUsersAssessments()) { sb.append(" and rset.identityKey in (select data.identity.key from assessmententry data ") .append(" where data.repositoryEntry.key=rset.repositoryEntryKey and data.subIdent=rset.olatResourceDetail") .append(" )"); } return sb; } private void decorateRSetQuery(TypedQuery<?> query, QTIStatisticSearchParams searchParams) { query.setParameter("resourceId", searchParams.getResourceableId()) .setParameter("resSubPath", searchParams.getResSubPath()); if(searchParams.getLimitToGroups() != null && searchParams.getLimitToGroups().size() > 0) { query.setParameter("baseGroups", searchParams.getLimitToGroups()); } } @Override public StatisticAssessment getAssessmentStatistics(QTIStatisticSearchParams searchParams) { StringBuilder sb = new StringBuilder(); sb.append("select rset.score, rset.duration, rset.isPassed from ").append(QTIStatisticResultSet.class.getName()).append(" rset "); decorateRSet(sb, searchParams); sb.append(" order by rset.duration asc"); TypedQuery<Object[]> rawDataQuery = dbInstance.getCurrentEntityManager() .createQuery(sb.toString(), Object[].class); decorateRSetQuery(rawDataQuery, searchParams); List<Object[]> rawDatas = rawDataQuery.getResultList(); int numOfPassed = 0; int numOfFailed = 0; double totalDuration = 0.0; double maxScore = 0.0; double minScore = Double.MAX_VALUE; double[] scores = new double[rawDatas.size()]; double[] durationSecondes = new double[rawDatas.size()]; double minDuration = Double.MAX_VALUE; double maxDuration = 0d; int dataPos = 0; for(Object[] rawData:rawDatas) { Boolean passed = (Boolean)rawData[2]; if(passed != null) { if(passed.booleanValue()) { numOfPassed++; } else { numOfFailed++; } } Float score = (Float)rawData[0]; if(score != null) { double scored = score.doubleValue(); scores[dataPos] = scored; maxScore = Math.max(maxScore, scored); minScore = Math.min(minScore, scored); } Long duration = (Long)rawData[1]; if(duration != null) { double durationd = duration.doubleValue(); double durationSeconde = Math.round(durationd / 1000d); durationSecondes[dataPos] = durationSeconde; totalDuration += durationd; minDuration = Math.min(minDuration, durationSeconde); maxDuration = Math.max(maxDuration, durationSeconde); } dataPos++; } if (rawDatas.size() == 0) { minScore = 0; } Statistics statisticsHelper = new Statistics(scores); int numOfParticipants = rawDatas.size(); StatisticAssessment stats = new StatisticAssessment(); stats.setNumOfParticipants(numOfParticipants); stats.setNumOfPassed(numOfPassed); stats.setNumOfFailed(numOfFailed); long averageDuration = Math.round(totalDuration / numOfParticipants); stats.setAverageDuration(averageDuration); stats.setAverage(statisticsHelper.getMean()); double range = maxScore - minScore; stats.setRange(range); stats.setMaxScore(maxScore); stats.setMinScore(minScore); stats.setStandardDeviation(statisticsHelper.getStdDev()); stats.setMedian(statisticsHelper.median()); stats.setMode(statisticsHelper.mode()); stats.setDurations(durationSecondes); stats.setScores(scores); return stats; } @Override public List<QTIStatisticResultSet> getAllResultSets(QTIStatisticSearchParams searchParams) { StringBuilder sb = new StringBuilder(); sb.append("select rset from qtistatsresultset rset "); decorateRSet(sb, searchParams); sb.append(" order by rset.duration asc"); TypedQuery<QTIStatisticResultSet> query = dbInstance.getCurrentEntityManager() .createQuery(sb.toString(), QTIStatisticResultSet.class); decorateRSetQuery(query, searchParams); return query.getResultList(); } @Override public List<QTIStatisticResult> getResults(QTIStatisticSearchParams searchParams) { StringBuilder sb = new StringBuilder(); sb.append("select res from qtistatsresult res ") .append(" inner join res.resultSet rset"); decorateRSet(sb, searchParams); TypedQuery<QTIStatisticResult> query = dbInstance.getCurrentEntityManager() .createQuery(sb.toString(), QTIStatisticResult.class); decorateRSetQuery(query, searchParams); return query.getResultList(); } @Override public List<StatisticItem> getStatisticPerItem(List<Item> items, QTIStatisticSearchParams searchParams, double numOfParticipants) { StringBuilder sb = new StringBuilder(); sb.append("select res.itemIdent, res.score, count(res.key) from qtistatsresult res ") .append(" inner join res.resultSet rset"); decorateRSet(sb, searchParams); sb.append(" group by res.itemIdent, res.score"); TypedQuery<Object[]> query = dbInstance.getCurrentEntityManager() .createQuery(sb.toString(), Object[].class); decorateRSetQuery(query, searchParams); List<Object[]> results = query.getResultList(); Map<String, StatisticItemHelper> itemToHelpers = new HashMap<>(); for (Object[] result : results) { String itemIdent = (String)result[0]; Float score = (Float)result[1]; Long count = (Long)result[2]; if(count == null || score == null || itemIdent == null) continue; StatisticItemHelper helper = itemToHelpers.get(itemIdent); if(helper == null) { helper = new StatisticItemHelper(); itemToHelpers.put(itemIdent, helper); } helper.count += count.longValue(); helper.totalScore += (count.longValue() * score.doubleValue()); for (Item item:items) { if(item.getIdent().equals(itemIdent)) { double maxValue = item.getQuestion().getMaxValue(); if(Math.abs(score.doubleValue() - maxValue) < 0.0001) { helper.countCorrectAnswers += count.longValue(); } } } } List<StatisticItem> averages = new ArrayList<>(); for (Item item:items) { StatisticItemHelper helper = itemToHelpers.get(item.getIdent()); if(helper == null) { averages.add(new StatisticItem(item, -1.0, -1.0, -1, -1)); } else { long numOfAnswersItem = helper.count; long numOfCorrectAnswers = helper.countCorrectAnswers; double average = (helper.totalScore / helper.count); double averageParticipants = (helper.totalScore / numOfParticipants); averages.add(new StatisticItem(item, average, averageParticipants, numOfAnswersItem, numOfCorrectAnswers)); } } return averages; } private static class StatisticItemHelper { private long count; private double totalScore; private long countCorrectAnswers; } @Override public StatisticsItem getItemStatistics(String itemIdent, double maxScore, QTIStatisticSearchParams searchParams) { StringBuilder sb = new StringBuilder(); sb.append("select res.score, count(res.key), avg(res.duration) from qtistatsresult res ") .append(" inner join res.resultSet rset"); decorateRSet(sb, searchParams); sb.append(" and res.itemIdent=:itemIdent and res.duration > 0 group by res.score"); TypedQuery<Object[]> query = dbInstance.getCurrentEntityManager() .createQuery(sb.toString(), Object[].class) .setParameter("itemIdent", itemIdent); decorateRSetQuery(query, searchParams); List<Object[]> results = query.getResultList(); if(results.isEmpty()) { return new StatisticsItem(); } int totalResults = 0; double totalScore = 0.0; double totalDuration = 0.0; long numOfCorrectAnswers = 0; long numOfIncorrectAnswers = 0; for(Object[] result:results) { long numOfResults = ((Long)result[1]).longValue(); //average double score = ((Float)result[0]).doubleValue(); totalScore += (score * numOfResults); totalResults += numOfResults; if((maxScore - score) < 0.0001) { numOfCorrectAnswers += numOfResults; } else { numOfIncorrectAnswers += numOfResults; } double averageDuration = ((Double)result[2]).doubleValue(); totalDuration += (averageDuration * numOfResults); } double averageScore = totalScore / totalResults; //difficulty (p-value) double difficulty = numOfCorrectAnswers / (double)totalResults; double averageDuration = totalDuration / totalResults; StatisticsItem stats = new StatisticsItem(); stats.setAverageDuration(Math.round(averageDuration)); stats.setAverageScore(averageScore); stats.setNumOfResults(totalResults); stats.setDifficulty(difficulty); stats.setNumOfCorrectAnswers(numOfCorrectAnswers); stats.setNumOfIncorrectAnswers(numOfIncorrectAnswers); return stats; } /** * calculates how many participants selected answer option 1 and/or option 2 * and/or option 3... * * @param aQuestion * @param olatResource * @param olatResourceDetail * @return */ @Override public List<StatisticChoiceOption> getNumOfAnswersPerSingleChoiceAnswerOption(Item item, QTIStatisticSearchParams searchParams) { List<StatisticAnswerOption> answerToNumberList = getStatisticAnswerOptionsOfItem(item.getIdent(), searchParams); List<Response> answerOptions = item.getQuestion().getResponses(); List<StatisticChoiceOption> numOfAnswersPerOption = new ArrayList<>(); for(int i=0; i<answerOptions.size(); i++) { Response response = answerOptions.get(i); String responseIdent = response.getIdent(); long num = 0; for(StatisticAnswerOption answerToNumber:answerToNumberList) { String answer = answerToNumber.getAnswer(); if(answer.indexOf(responseIdent) >= 0) { num += answerToNumber.getCount(); } } numOfAnswersPerOption.add(new StatisticChoiceOption(response, num)); } return numOfAnswersPerOption; } /** * calculates the percentage of participants that answered a answer option * correctly.<br> * Number at index 0 = answer option 1, Number at index 1 = answer option 2, * etc. * * @param item * @param numberOfParticipants * @param olatResource * @param olatResourceDetail * @return */ @Override public List<StatisticChoiceOption> getNumOfRightAnsweredMultipleChoice(Item item, QTIStatisticSearchParams searchParams) { List<StatisticAnswerOption> answerToNumberList = getStatisticAnswerOptionsOfItem(item.getIdent(), searchParams); List<Response> responses = item.getQuestion().getResponses(); List<StatisticChoiceOption> percentageRightAnswered = new ArrayList<StatisticChoiceOption>(); for (Response response:responses) { String answerIdent = response.getIdent(); long num = 0; for(StatisticAnswerOption answerToNumber:answerToNumberList) { String answer = answerToNumber.getAnswer(); if(answer.indexOf(answerIdent) >= 0) { num += answerToNumber.getCount(); } } percentageRightAnswered.add(new StatisticChoiceOption(response, num)); } return percentageRightAnswered; } @Override public List<StatisticKPrimOption> getNumbersInKPrim(Item item, QTIStatisticSearchParams searchParams) { List<StatisticAnswerOption> rawDatas = getStatisticAnswerOptionsOfItem(item.getIdent(), searchParams); List<Response> responses = item.getQuestion().getResponses(); List<StatisticKPrimOption> kprimPoints = new ArrayList<>(); for(Response response:responses) { String answerIdent = response.getIdent(); boolean isCorrect = response.isCorrect(); String rightFlag = answerIdent + ":" + (isCorrect ? "correct" : "wrong"); String wrongFlag = answerIdent + ":" + (isCorrect ? "wrong" : "correct"); long numCorrect = 0; long numIncorrect = 0; long numUnanswered = 0; for(StatisticAnswerOption rawData:rawDatas) { String answer = rawData.getAnswer(); if(answer.indexOf(rightFlag) >= 0) { numCorrect += rawData.getCount(); } else if(answer.indexOf(wrongFlag) >= 0) { numIncorrect += rawData.getCount(); } else { numUnanswered += rawData.getCount(); } } kprimPoints.add(new StatisticKPrimOption(response, numCorrect, numIncorrect, numUnanswered)); } return kprimPoints; } @Override public List<StatisticFIBOption> getStatisticAnswerOptionsFIB(Item item, QTIStatisticSearchParams searchParams) { List<StatisticFIBOption> options = new ArrayList<>(); Map<String,StatisticFIBOption> optionMap = new HashMap<>(); boolean groupBy = true; List<Response> responses = item.getQuestion().getResponses(); for(Response response:responses) { if(response instanceof FIBResponse) { FIBResponse fibResponse = (FIBResponse)response; if(FIBResponse.TYPE_BLANK.equals(fibResponse.getType())) { String ident = fibResponse.getIdent(); String[] correctFIBs = fibResponse.getCorrectBlank().split(";"); if(correctFIBs == null || correctFIBs.length == 0) { continue; } StatisticFIBOption option = new StatisticFIBOption(); option.setCorrectBlank(correctFIBs[0]); option.setAlternatives(Arrays.asList(correctFIBs)); boolean caseSensitive = "Yes".equals(fibResponse.getCaseSensitive()); groupBy &= !caseSensitive; option.setCaseSensitive(caseSensitive); option.setPoints(fibResponse.getPoints()); options.add(option); optionMap.put(ident, option); } } } List<StatisticAnswerOption> answerOptions = getStatisticAnswerOptionsOfItem(item.getIdent(), searchParams, groupBy); for(StatisticAnswerOption answerOption:answerOptions) { long count = answerOption.getCount(); String concatenedAnswer = answerOption.getAnswer(); Map<String,String> parsedAnswerMap = QTIResultManager.parseResponseStrAnswers(concatenedAnswer); for(Map.Entry<String, String> parsedAnswerEntry: parsedAnswerMap.entrySet()) { String ident = parsedAnswerEntry.getKey(); StatisticFIBOption option = optionMap.get(ident); if(option == null) { continue; } String text = parsedAnswerEntry.getValue(); boolean correct; if(option.isCaseSensitive()) { correct = option.getAlternatives().contains(text); } else { correct = false; for(String alt:option.getAlternatives()) { if(alt.equalsIgnoreCase(text)) { correct = true; } } } if(correct) { option.setNumOfCorrect(option.getNumOfCorrect() + count); } else { option.setNumOfIncorrect(option.getNumOfIncorrect() + count); option.getWrongAnswers().add(text); } } } return options; } @Override public List<StatisticAnswerOption> getStatisticAnswerOptionsOfItem(String itemIdent, QTIStatisticSearchParams searchParams) { return getStatisticAnswerOptionsOfItem(itemIdent, searchParams, true); } private List<StatisticAnswerOption> getStatisticAnswerOptionsOfItem(String itemIdent, QTIStatisticSearchParams searchParams, boolean groupBy) { //the group by of mysql is case insensitive if(!groupBy && !dbInstance.getDbVendor().equals("mysql")) { groupBy = true; } StringBuilder sb = new StringBuilder(); sb.append("select res.answer, count(res.key) from qtistatsresult res ") .append(" inner join res.resultSet rset"); decorateRSet(sb, searchParams); sb.append(" and res.itemIdent=:itemIdent and res.duration > 0 "); if(groupBy) { sb.append("group by res.answer"); } else { sb.append("group by res.key"); } TypedQuery<Object[]> query = dbInstance.getCurrentEntityManager().createQuery(sb.toString(), Object[].class) .setParameter("itemIdent", itemIdent); decorateRSetQuery(query, searchParams); List<Object[]> results = query.getResultList(); if(results.isEmpty()) { return Collections.emptyList(); } List<StatisticAnswerOption> answerToNumberList = new ArrayList<>(); for(Object[] result:results) { String answer = (String)result[0]; Long numOfAnswers = (Long)result[1]; answerToNumberList.add(new StatisticAnswerOption(answer, numOfAnswers.longValue())); } return answerToNumberList; } @Override public List<StatisticSurveyItem> getStatisticAnswerOptions(QTIStatisticSearchParams searchParams, List<Item> items) { StringBuilder sb = new StringBuilder(); sb.append("select res.itemIdent, res.answer, count(res.key) from qtistatsresult res ") .append(" inner join res.resultSet rset"); decorateRSet(sb, searchParams) .append(" and res.duration > 0") .append(" group by res.itemIdent, res.answer") .append(" order by res.itemIdent"); TypedQuery<Object[]> query = dbInstance.getCurrentEntityManager() .createQuery(sb.toString(), Object[].class); decorateRSetQuery(query, searchParams); List<Object[]> results = query.getResultList(); if(results.isEmpty()) { return Collections.emptyList(); } Map<String, Item> identToItemMap = new HashMap<>(); for(Item item:items) { identToItemMap.put(item.getIdent(), item); } StatisticSurveyItem currentItem = null; Map<Item, StatisticSurveyItem> itemToStatisticsMap = new HashMap<>(); for(Object[] result:results) { String itemIdent = (String)result[0]; String answer = (String)result[1]; Long numOfAnswers = (Long)result[2]; Item item = identToItemMap.get(itemIdent); if(currentItem == null || !currentItem.getItem().getIdent().equals(itemIdent)) { currentItem = new StatisticSurveyItem(item); itemToStatisticsMap.put(item, currentItem); } Response response = findResponses(item, answer); currentItem.getResponses().add(new StatisticSurveyItemResponse(response, answer, numOfAnswers)); } List<StatisticSurveyItem> reorderList = new ArrayList<>(); for(Item item:items) { StatisticSurveyItem statsItem = itemToStatisticsMap.get(item); if(statsItem != null) { reorderList.add(statsItem); } } return reorderList; } private Response findResponses(Item item, String answer) { List<Response> responses = item.getQuestion().getResponses(); if(responses != null) { for(Response response:responses) { if(answer.indexOf(response.getIdent()) > 0) { return response; } } } return null; } @Override public List<String> getAnswers(String itemIdent, QTIStatisticSearchParams searchParams) { StringBuilder sb = new StringBuilder(); sb.append("select res.answer from qtistatsresult res ") .append(" inner join res.resultSet rset"); decorateRSet(sb, searchParams); sb.append(" and res.itemIdent=:itemIdent and res.duration > 0"); TypedQuery<String> query = dbInstance.getCurrentEntityManager() .createQuery(sb.toString(), String.class) .setParameter("itemIdent", itemIdent); decorateRSetQuery(query, searchParams); return query.getResultList(); } }