/*==========================================================================*\ | $Id: AssignmentSummary.java,v 1.2 2011/03/07 18:57:09 stedwar2 Exp $ |*-------------------------------------------------------------------------*| | Copyright (C) 2006-2011 Virginia Tech | | This file is part of Web-CAT. | | Web-CAT is free software; you can redistribute it and/or modify | it under the terms of the GNU Affero General Public License as published | by the Free Software Foundation; either version 3 of the License, or | (at your option) any later version. | | Web-CAT is distributed in the hope that it will be useful, | but WITHOUT ANY WARRANTY; without even the implied warranty of | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | GNU General Public License for more details. | | You should have received a copy of the GNU Affero General Public License | along with Web-CAT; if not, see <http://www.gnu.org/licenses/>. \*==========================================================================*/ package org.webcat.grader.graphs; import com.webobjects.foundation.*; import er.extensions.eof.ERXConstant; import java.io.*; import org.apache.log4j.Logger; import org.jfree.data.xy.AbstractIntervalXYDataset; import org.jfree.data.xy.IntervalXYDataset; import org.webcat.core.*; // ------------------------------------------------------------------------- /** * Represents cumulative, graphable score performance data for all * submissions to an assignment offering or an assignment. * * @author Stephen Edwards * @author Last changed by $Author: stedwar2 $ * @version $Revision: 1.2 $, $Date: 2011/03/07 18:57:09 $ */ public class AssignmentSummary implements org.webcat.core.MutableContainer, Serializable { //~ Constructors .......................................................... // ---------------------------------------------------------- /** * Creates an empty array. */ public AssignmentSummary() { this( DEFAULT_MAX_SCORE, DEFAULT_NUMBER_OF_DIVISIONS ); } // ---------------------------------------------------------- /** * Creates an empty assignment summary, set up with the given maximum * score and number of divisions (buckets) used for maintaining * summary data. * @param maxScore the maximum score for this assignment * @param numberOfDivisions the number of buckets, or ranges, in which * scores are placed */ public AssignmentSummary( double maxScore, int numberOfDivisions ) { setMaxScore( maxScore, numberOfDivisions ); } //~ Public Constants ...................................................... public static final double DEFAULT_MAX_SCORE = 100.0; public static final int DEFAULT_NUMBER_OF_DIVISIONS = 10; public static final String STUDENT_SERIES_KEY = "Students"; //~ Public Methods ........................................................ //---------------------------------------------------------- /** * Clear all the data stored in this summary. */ public void clear() { if ( numInBin == null || numInBin.length != numBins ) { numInBin = new int[numBins]; } if ( bin == null || bin.length != numBins ) { bin = new float[numBins]; } for ( int i = 0; i < numBins; i++ ) { numInBin[i] = 0; bin[i] = 0.0f; } sum = 0.0f; numStudents = 0; if ( percentages != null && percentages.length != numBins ) { percentages = null; } percentagesAreUpToDate = false; setHasChanged( true ); } //---------------------------------------------------------- /** * Calculate the percentage distribution for each division across the * entire score range. The resulting array should be treated as * read-only. * @return an array with one slot per division, containing a percentage * (a float between 0 and 1) corresponding to the number of scores in * that division. */ public float[] percentageDistribution() { ensurePercentagesAreCurrent(); if ( log.isDebugEnabled() ) { log.debug( "percentageDistribution():\n" + printable() ); } return percentages; } //---------------------------------------------------------- /** * Get the percentage distribution as a chartable data set. * @return a JFreeChart category dataset */ public IntervalXYDataset frequencyDataset() { if ( dataset == null ) { dataset = new Dataset(); } if ( log.isDebugEnabled() ) { log.debug( "frequencyDataset():\n" + printable() ); } return dataset; } //---------------------------------------------------------- /** * Generate a small, human-readable representation. * @return A printable summary */ public String toString() { float mean = mean(); int start = (int)mean; int tenth = (int)( ( mean - start ) * 10 ); return "" + start + "." + tenth + "(" + students() + ")"; } //---------------------------------------------------------- /** * Calculate the mean score for this summary. * @return the mean (average) score */ public float mean() { if ( numStudents > 0 ) return sum / numStudents; else return 0.0f; } //---------------------------------------------------------- /** * Get the total number of score entries used in generating this * numerical summary. A student may make 20 submissions for an * assignment, but only the most recent submission is included in * statistical totalling. This count includes only the most recent * submission for each student, so it is equivalent to the number of * distinct students covered by this summary. * @return the number of students who have made submissions */ public int students() { return numStudents; } //---------------------------------------------------------- /** * Get the total number of submissions that have been entered into * this summary. A student may make 20 submissions for an assignment, * but only the most recent submission is included in statistical * totalling. This count includes all submissions considered, not just * the most recent for each student. * @return the number of submissions */ public int submissions() { return numSubmissions; } //---------------------------------------------------------- /** * Get the number of divisions, that is, buckets or ranges, into which * scores in this summary are divided. * @return the number of score divisions */ public int numberOfDivisions() { return numBins; } //---------------------------------------------------------- /** * Adds a single submission from a new student, who does not already * have data in this summary. * @param score the submission score */ public void addSubmission( double score ) { if ( log.isDebugEnabled() ) { log.debug( "addSubmission(" + score + ") before:\n" + printable() ); } float val = (float)score; int binNo = binFor( val ); bin[binNo] += val; numInBin[binNo]++; sum += val; numStudents++; numSubmissions++; percentagesAreUpToDate = false; setHasChanged( true ); if ( log.isDebugEnabled() ) { log.debug( "addSubmission(" + score + ") after:\n" + printable() ); } } //---------------------------------------------------------- /** * Removes a single submission for a student who has not made any * other submissions. * @param score the submission score */ public void removeSubmission( double score ) { if ( log.isDebugEnabled() ) { log.debug( "removeSubmission(" + score + ") before:\n" + printable() ); } float val = (float)score; int binNo = binFor( val ); bin[binNo] -= val; numInBin[binNo]--; sum -= val; numStudents--; percentagesAreUpToDate = false; setHasChanged( true ); if ( log.isDebugEnabled() ) { log.debug( "removeSubmission(" + score + ") after:\n" + printable() ); } } //---------------------------------------------------------- /** * Replaces (updates) a student's submission score with a newer * score, when the student has already submitted before. * @param oldScore the submission score to replace * @param newScore the new score to replace it with */ public void updateSubmission( double oldScore, double newScore ) { if ( log.isDebugEnabled() ) { log.debug( "updateSubmission(" + oldScore + ", " + newScore + ") before:\n" + printable() ); } removeSubmission( oldScore ); addSubmission( newScore ); if ( log.isDebugEnabled() ) { log.debug( "updateSubmission(" + oldScore + ", " + newScore + ") after:\n" + printable() ); } } //---------------------------------------------------------- /** * Get the maximum score for the assignment associated with this * summary. This is usually the maximum computer-graded score, including * correctness/testing points and static analysis tool points, but not * late penalties or manually graded points. * @return the maximum score */ public float maxScore() { return maxScore; } //---------------------------------------------------------- /** * Sets the maximum possible score for this assignment summary, * clearing out any data that may already be stored here. * @param maxScore the new maximum score for this summary */ public void setMaxScore( double maxScore ) { assert maxScore > 0.0; this.maxScore = (float)maxScore; if ( binBoundary == null || binBoundary.length != numBins ) { binBoundary = new float[numBins]; } for ( int i = 1; i < numBins; i++ ) { binBoundary[i - 1] = ( i * this.maxScore ) / numBins; } binBoundary[numBins - 1] = this.maxScore; clear(); if ( log.isDebugEnabled() ) { log.debug( "setMaxScore(" + maxScore + ") after:\n" + printable() ); } } //---------------------------------------------------------- /** * Sets the maximum possible score for this assignment summary, together * with the number of divisions this range should be divided into, * clearing out any data that may already be stored here. * @param maxScore the new maximum score for this summary * @param numberOfDivisions the number of buckets, or ranges, in which * scores are placed */ public void setMaxScore( double maxScore, int numberOfDivisions ) { assert maxScore > 0.0 && numberOfDivisions > 0; numBins = numberOfDivisions; setMaxScore( maxScore ); } //---------------------------------------------------------- /** * Test this object to see if it has been changed (mutated) since it * was last saved. * @return true if this dictionary has been changed */ public boolean hasChanged() { log.debug( "hasChanged() = " + hasChanged ); return hasChanged; } //---------------------------------------------------------- /** * Mark this object as having changed (mutated) since it * was last saved. * @param value true if this dictionary has been changed */ public void setHasChanged( boolean value ) { log.debug( "setHasChanged() = " + value ); hasChanged = value; if ( hasChanged ) { if ( parent != null ) { parent.setHasChanged( value ); } if ( owner != null ) { owner.mutableContainerHasChanged(); } } } //---------------------------------------------------------- /** * Set the enclosing container that holds this one, if any. * @param parent a reference to the enclosing container */ public void setParent( MutableContainer parent ) { this.parent = parent; } //---------------------------------------------------------- /** * Set the enclosing container that holds this one, if any. * Also, recursively cycle through all contained mutable containers, * resetting their parents to this object as well. * @param parent a reference to the enclosing container */ public void setParentRecursively( MutableContainer parent ) { this.parent = parent; } //---------------------------------------------------------- /** * Examine all contained objects for mutable containers, and reset * the parent relationships for any that are found. Any NS containers * found will be converted to mutable versions. * @param recurse if true, force the reset to cascade recursively down * the tree, rather than just affecting this node's * immediate children. */ public void resetChildParents( boolean recurse ) { // cannot have any mutable containers as children, so there is // nothing to do. } //---------------------------------------------------------- /** * Retrieve the enclosing container that holds this one, if any. * @return a reference to the enclosing container */ public MutableContainer parent() { return parent; } //---------------------------------------------------------- /** * Set the owner of this container. * @param owner the owner of this container container */ public void setOwner( MutableContainerOwner owner ) { this.owner = owner; } //---------------------------------------------------------- /** * Replace this container's contents by copying from another (and * assuming parent ownership over any subcontainers). The container * is free to assume the argument is of a compatible container type. * @param other the container to copy from */ public void copyFrom( MutableContainer other ) { AssignmentSummary rhs = (AssignmentSummary)other; setMaxScore( rhs.maxScore(), rhs.numberOfDivisions() ); addFrom( rhs ); } //---------------------------------------------------------- /** * Determine whether this summary is compatible with another, meaning * that both summaries have the same number of divisions and the same * maximum score. * @param summary the container to check against * @return true if the two summaries have the same maximum score and * number of divisions */ public boolean isCompatibleWith( AssignmentSummary summary ) { return maxScore() == summary.maxScore() && numberOfDivisions() == summary.numberOfDivisions(); } //---------------------------------------------------------- /** * Add all of the entires in the given summary to this summary. This * method can be used to combine multiple summaries into one aggregate * summary. The maximum score and number of divisions must be the * same in both containers. * @param summary the container to add from */ public void addFrom( AssignmentSummary summary ) { assert isCompatibleWith( summary ); sum += summary.sum; numStudents += summary.students(); numSubmissions += summary.submissions(); for ( int i = 0; i < numBins; i++ ) { numInBin[i] += summary.numInBin[i]; bin[i] += summary.bin[i]; binBoundary[i] += summary.binBoundary[i]; } } // ---------------------------------------------------------- /** * This is the conversion method that serializes this object for * storage in the database. It uses java serialization to serialize * this object into bytes within an NSData object. * * @return An NSData object containing the serialized bytes of this object. */ public NSData archiveData() { if ( log.isDebugEnabled() ) { log.debug( "archiveData():\n" + printable() ); } ByteArrayOutputStream bos = new ByteArrayOutputStream( kOverheadAdjustment + numBins * kCountBytesFactor ); NSData result = null; try { ObjectOutputStream oos = new ObjectOutputStream( bos ); oos.writeObject( this ); oos.flush(); oos.close(); result = new NSData( bos.toByteArray() ); } catch ( IOException ioe ) { log.error( "IOException while saving AssignmentSummary to NSData", ioe ); if ( kThrowOnError ) { throw new NSForwardException( ioe ); } } return result; } // ---------------------------------------------------------- /** * This is the factory method used to recreate an object from a * database attribute. It uses java Serialization to turn bytes from an * NSData into a reconstituted Object. * * @param data This is the NSData holding the previously serialized bytes. * @return The un-serialized Object. */ public static AssignmentSummary objectWithArchiveData( NSData data ) { if ( data == null ) return new AssignmentSummary(); ByteArrayInputStream bis = new ByteArrayInputStream( data.bytes() ); AssignmentSummary result = null; Throwable exception = null; try { ObjectInputStream ois = new ObjectInputStream( bis ); result = (AssignmentSummary)ois.readObject(); } catch ( IOException ioe ) { exception = ioe; } catch ( ClassNotFoundException cnfe ) { // exception = cnfe; result = new AssignmentSummary(); } if ( exception != null ) { log.error( "IOException while restoring AssignmentSummary from NSData", exception ); if ( kThrowOnError ) { throw new NSForwardException( exception ); } } if ( log.isDebugEnabled() && result != null ) { log.debug( "objectWithArchiveData():\n" + result.printable() ); } return result; } //---------------------------------------------------------- /** * Calculate the bin number that contains a given score value. * @param score the score to use * @return the index of the bin where this score belongs */ public int binFor( float score ) { for ( int index = 0; index < numBins - 1; index++ ) { if ( binBoundary[index] > score ) return index; } return numBins - 1; } //---------------------------------------------------------- /** * Interpolate a position between 0 and width-1 where a given * score would fall within a given bin. This method performs a simple * linear interpolation based on the upper and lower bounds for the bin. * @param score the score to use * @param binNo the bin in which to perform the interpolation * @param width the size (resolution) of the space to interpolate on * @return a number >= 0 and < width that is proportional to the score's * linearly interpolated position within the bin boundaries */ public int interpolateInBin( float score, int binNo, int width ) { float lowerBound = 0.0f; if ( binNo > 0 ) { lowerBound = binBoundary[binNo - 1]; } float val = ( score - lowerBound ) / binBoundary[0]; int result = Math.round( val * width ); if ( result >= width ) { result = width - 1; } return result; } //---------------------------------------------------------- /** * Generate a detailed printable view of this graph summary. * @return the printable view as a string */ public String printable() { StringBuffer buffer = new StringBuffer(); buffer.append( "AssignmentSummary, bin count = " ); buffer.append( numBins ); buffer.append( "\n max score = " ); buffer.append( maxScore ); buffer.append( ", sum = " ); buffer.append( sum ); buffer.append( ", students = " ); buffer.append( numStudents ); buffer.append( ", subs = " ); buffer.append( numSubmissions ); buffer.append( ", changed = " ); buffer.append( hasChanged ); buffer.append( ", up-to-date = " ); buffer.append( percentagesAreUpToDate ); buffer.append( "\n" ); for ( int i = 0; i < numBins; i++ ) { buffer.append( " bin " ); buffer.append( i ); buffer.append( ": boundary = " ); if ( binBoundary == null ) buffer.append( "null" ); else buffer.append( binBoundary[i] ); buffer.append( ", count = " ); if ( numInBin == null ) buffer.append( "null" ); else buffer.append( numInBin[i] ); buffer.append( ", sum = " ); if ( bin == null ) buffer.append( "null" ); else buffer.append( bin[i] ); buffer.append( ", % = " ); if ( percentages == null ) buffer.append( "null" ); else buffer.append( percentages[i] ); buffer.append( "\n" ); } return buffer.toString(); } //~ Dataset Class ......................................................... @SuppressWarnings("unchecked") private class Dataset extends AbstractIntervalXYDataset { // ---------------------------------------------------------- public int getSeriesCount() { return 1; } // ---------------------------------------------------------- public Comparable getSeriesKey( int series ) { return ( series == 0 ) ? STUDENT_SERIES_KEY : null; } // ---------------------------------------------------------- public int indexOf( Comparable seriesKey ) { return STUDENT_SERIES_KEY.equals( seriesKey ) ? 0 : -1; } // ---------------------------------------------------------- public int getItemCount( int series ) { return series == 0 ? numBins : -1; } // ---------------------------------------------------------- public Number getX( int series, int item ) { return new Double( getXValue( series, item ) ); } // ---------------------------------------------------------- public double getXValue( int series, int item ) { if ( item == 0 ) { return binBoundary[item] / 2.0; } else { return ( binBoundary[item - 1] + binBoundary[item] ) / 2.0; } } // ---------------------------------------------------------- public Number getY( int series, int item ) { ensurePercentagesAreCurrent(); return ERXConstant.integerForInt( numInBin[item] ); } // ---------------------------------------------------------- public double getYValue( int series, int item ) { ensurePercentagesAreCurrent(); return numInBin[item]; } // ---------------------------------------------------------- public Number getEndX( int series, int item ) { return new Float( binBoundary[item] ); } // ---------------------------------------------------------- public double getEndXValue( int series, int item ) { return binBoundary[item]; } // ---------------------------------------------------------- public Number getEndY( int series, int item ) { return getY( series, item ); } // ---------------------------------------------------------- public double getEndYValue( int series, int item ) { return getYValue( series, item ); } // ---------------------------------------------------------- public Number getStartX( int series, int item ) { return item == 0 ? (Number)ERXConstant.ZeroInteger : new Float( binBoundary[item - 1] ); } // ---------------------------------------------------------- public double getStartXValue( int series, int item ) { return item == 0 ? 0.0 : binBoundary[item - 1]; } // ---------------------------------------------------------- public Number getStartY( int series, int item ) { return getY( series, item ); } // ---------------------------------------------------------- public double getStartYValue( int series, int item ) { return getYValue( series, item ); } } //~ Private Methods ....................................................... private void ensurePercentagesAreCurrent() { if ( !percentagesAreUpToDate || percentages == null ) { if ( percentages == null ) { percentages = new float[numBins]; } for ( int i = 0; i < numBins; i++ ) { if ( numStudents > 0 ) { percentages[i] = ( (float)numInBin[i] ) / ( (float)numStudents ); } else { percentages[i] = 0.0f; } } percentagesAreUpToDate = true; } } //~ Instance/static variables ............................................. private int numBins; private float maxScore; private int[] numInBin; private float[] bin; private float[] binBoundary; private float sum; private int numStudents; private int numSubmissions; private transient float[] percentages; private transient boolean percentagesAreUpToDate; private boolean hasChanged = false; private MutableContainer parent = null; private transient MutableContainerOwner owner = null; private transient IntervalXYDataset dataset; /** * This helps create the ByteArrayOutputStream with a good space estimate. */ private static final int kOverheadAdjustment = 128; /** * This also helps create the ByteArrayOutputStream with a good space * estimate. */ private static final int kCountBytesFactor = 16; /** * This determines, when an error occurs, if we should throw an * NSForwardException or just return null. */ private static final boolean kThrowOnError = true; static final long serialVersionUID = 3641621406572343011L; static Logger log = Logger.getLogger( AssignmentSummary.class ); }