Update score calculation

The value of a score is now a double. For boolean habits, this number goes from zero
to one and corresponds to the percentage. For numerical habits, it now corresponds
to a weighted average of the checkmark values. Also, for non-daily boolean habits, the
score now increases with implicit checkmarks.
This commit is contained in:
2017-03-25 12:31:19 -04:00
parent d3b540199c
commit 5653651c0d
13 changed files with 141 additions and 158 deletions

View File

@@ -12,7 +12,7 @@ android {
minSdkVersion 15 minSdkVersion 15
targetSdkVersion 25 targetSdkVersion 25
buildConfigField "Integer", "databaseVersion", "16" buildConfigField "Integer", "databaseVersion", "17"
buildConfigField "String", "databaseFilename", "\"uhabits.db\"" buildConfigField "String", "databaseFilename", "\"uhabits.db\""
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"

View File

@@ -51,7 +51,7 @@ public class CheckmarkWidgetViewTest extends BaseViewTest
view = new CheckmarkWidgetView(targetContext); view = new CheckmarkWidgetView(targetContext);
int color = ColorUtils.getAndroidTestColor(habit.getColor()); int color = ColorUtils.getAndroidTestColor(habit.getColor());
double score = habit.getScores().getTodayValue(); double score = habit.getScores().getTodayValue();
float percentage = (float) score / Score.MAX_VALUE; float percentage = (float) score;
view.setActiveColor(color); view.setActiveColor(color);
view.setCheckmarkValue(habit.getCheckmarks().getTodayValue()); view.setCheckmarkValue(habit.getCheckmarks().getTodayValue());

View File

@@ -0,0 +1,5 @@
DROP TABLE Score;
CREATE TABLE Score (Id INTEGER PRIMARY KEY AUTOINCREMENT, habit INTEGER REFERENCES Habits(Id), score REAL, timestamp INTEGER);
CREATE INDEX idx_score_habit_timestamp on score(habit, timestamp);
delete from Streak;
delete from Checkmarks;

View File

@@ -108,15 +108,15 @@ public class ScoreChart extends ScrollableChart
Random random = new Random(); Random random = new Random();
scores = new LinkedList<>(); scores = new LinkedList<>();
int previous = Score.MAX_VALUE / 2; double previous = 0.5f;
long timestamp = DateUtils.getStartOfToday(); long timestamp = DateUtils.getStartOfToday();
long day = DateUtils.millisecondsInOneDay; long day = DateUtils.millisecondsInOneDay;
for (int i = 1; i < 100; i++) for (int i = 1; i < 100; i++)
{ {
int step = Score.MAX_VALUE / 10; double step = 0.1f;
int current = previous + random.nextInt(step * 2) - step; double current = previous + random.nextDouble() * step * 2 - step;
current = Math.max(0, Math.min(Score.MAX_VALUE, current)); current = Math.max(0, Math.min(1.0f, current));
scores.add(new Score(timestamp, current)); scores.add(new Score(timestamp, current));
previous = current; previous = current;
timestamp -= day; timestamp -= day;
@@ -190,8 +190,7 @@ public class ScoreChart extends ScrollableChart
double score = scores.get(offset).getValue(); double score = scores.get(offset).getValue();
long timestamp = scores.get(offset).getTimestamp(); long timestamp = scores.get(offset).getTimestamp();
double relativeScore = score / Score.MAX_VALUE; int height = (int) (columnHeight * score);
int height = (int) (columnHeight * relativeScore);
rect.set(0, 0, baseSize, baseSize); rect.set(0, 0, baseSize, baseSize);
rect.offset(k * columnWidth + (columnWidth - baseSize) / 2, rect.offset(k * columnWidth + (columnWidth - baseSize) / 2,

View File

@@ -140,7 +140,7 @@ public class HabitCardView extends FrameLayout
public void setScore(double score) public void setScore(double score)
{ {
float percentage = (float) score / Score.MAX_VALUE; float percentage = (float) score;
scoreRing.setPercentage(percentage); scoreRing.setPercentage(percentage);
scoreRing.setPrecision(1.0f / 16); scoreRing.setPrecision(1.0f / 16);
postInvalidate(); postInvalidate();

View File

@@ -105,9 +105,9 @@ public class OverviewCard extends HabitCard
private void initEditMode() private void initEditMode()
{ {
color = ColorUtils.getAndroidTestColor(1); color = ColorUtils.getAndroidTestColor(1);
cache.todayScore = Score.MAX_VALUE * 0.6f; cache.todayScore = 0.6f;
cache.lastMonthScore = Score.MAX_VALUE * 0.42f; cache.lastMonthScore = 0.42f;
cache.lastYearScore = Score.MAX_VALUE * 0.75f; cache.lastYearScore = 0.75f;
refreshColors(); refreshColors();
refreshScore(); refreshScore();
} }
@@ -121,11 +121,9 @@ public class OverviewCard extends HabitCard
private void refreshScore() private void refreshScore()
{ {
float todayPercentage = cache.todayScore / Score.MAX_VALUE; float todayPercentage = cache.todayScore;
float monthDiff = float monthDiff = todayPercentage - cache.lastMonthScore;
todayPercentage - (cache.lastMonthScore / Score.MAX_VALUE); float yearDiff = todayPercentage - cache.lastYearScore;
float yearDiff =
todayPercentage - (cache.lastYearScore / Score.MAX_VALUE);
scoreRing.setPercentage(todayPercentage); scoreRing.setPercentage(todayPercentage);
scoreLabel.setText(String.format("%.0f%%", todayPercentage * 100)); scoreLabel.setText(String.format("%.0f%%", todayPercentage * 100));

View File

@@ -202,7 +202,7 @@ public class HabitsCSVExporter
checksWriter.write(String.valueOf(checkmarks.get(j)[i])); checksWriter.write(String.valueOf(checkmarks.get(j)[i]));
checksWriter.write(DELIMITER); checksWriter.write(DELIMITER);
String score = String score =
String.format("%.4f", ((float) scores.get(j)[i]) / Score.MAX_VALUE); String.format("%.4f", ((float) scores.get(j)[i]));
scoresWriter.write(score); scoresWriter.write(score);
scoresWriter.write(DELIMITER); scoresWriter.write(DELIMITER);
} }

View File

@@ -21,16 +21,13 @@ package org.isoron.uhabits.models;
import org.apache.commons.lang3.builder.*; import org.apache.commons.lang3.builder.*;
import static java.lang.Math.*;
/** /**
* Represents how strong a habit is at a certain date. * Represents how strong a habit is at a certain date.
*/ */
public final class Score public final class Score
{ {
/**
* Maximum score value attainable by any habit.
*/
public static final int MAX_VALUE = 19259478;
/** /**
* Timestamp of the day to which this score applies. Time of day should be * Timestamp of the day to which this score applies. Time of day should be
* midnight (UTC). * midnight (UTC).
@@ -55,27 +52,20 @@ public final class Score
* The frequency of the habit is the number of repetitions divided by the * The frequency of the habit is the number of repetitions divided by the
* length of the interval. For example, a habit that should be repeated 3 * length of the interval. For example, a habit that should be repeated 3
* times in 8 days has frequency 3.0 / 8.0 = 0.375. * times in 8 days has frequency 3.0 / 8.0 = 0.375.
* <p>
* The checkmarkValue should be UNCHECKED, CHECKED_IMPLICITLY or
* CHECK_EXPLICITLY.
* *
* @param frequency the frequency of the habit * @param frequency the frequency of the habit
* @param previousScore the previous score of the habit * @param previousScore the previous score of the habit
* @param checkmarkValue the value of the current checkmark * @param checkmarkValue the value of the current checkmark
* @return the current score * @return the current score
*/ */
public static int compute(double frequency, public static double compute(double frequency,
double previousScore, double previousScore,
int checkmarkValue) int checkmarkValue)
{ {
double multiplier = Math.pow(0.5, 1.0 / (14.0 / frequency - 1)); double multiplier = pow(0.5, frequency / 13.0);
int score = (int) (previousScore * multiplier);
if (checkmarkValue == Checkmark.CHECKED_EXPLICITLY) double score = previousScore * multiplier;
{ score += checkmarkValue * (1 - multiplier);
score += 1000000;
score = Math.min(score, Score.MAX_VALUE);
}
return score; return score;
} }

View File

@@ -132,7 +132,7 @@ public abstract class ScoreList implements Iterable<Score>
public List<Score> groupBy(DateUtils.TruncateField field) public List<Score> groupBy(DateUtils.TruncateField field)
{ {
computeAll(); computeAll();
HashMap<Long, ArrayList<Long>> groups = getGroupedValues(field); HashMap<Long, ArrayList<Double>> groups = getGroupedValues(field);
List<Score> scores = groupsToAvgScores(groups); List<Score> scores = groupsToAvgScores(groups);
Collections.sort(scores, (s1, s2) -> s2.compareNewer(s1)); Collections.sort(scores, (s1, s2) -> s2.compareNewer(s1));
return scores; return scores;
@@ -173,7 +173,7 @@ public abstract class ScoreList implements Iterable<Score>
{ {
String timestamp = dateFormat.format(s.getTimestamp()); String timestamp = dateFormat.format(s.getTimestamp());
String score = String score =
String.format("%.4f", ((float) s.getValue()) / Score.MAX_VALUE); String.format("%.4f", s.getValue());
out.write(String.format("%s,%s\n", timestamp, score)); out.write(String.format("%s,%s\n", timestamp, score));
} }
} }
@@ -277,6 +277,8 @@ public abstract class ScoreList implements Iterable<Score>
for (int i = 0; i < checkmarkValues.length; i++) for (int i = 0; i < checkmarkValues.length; i++)
{ {
int value = checkmarkValues[checkmarkValues.length - i - 1]; int value = checkmarkValues[checkmarkValues.length - i - 1];
if(!habit.isNumerical() && value > 0) value = 1;
previousValue = Score.compute(freq, previousValue, value); previousValue = Score.compute(freq, previousValue, value);
scores.add(new Score(from + day * i, previousValue)); scores.add(new Score(from + day * i, previousValue));
} }
@@ -285,9 +287,9 @@ public abstract class ScoreList implements Iterable<Score>
} }
@NonNull @NonNull
private HashMap<Long, ArrayList<Long>> getGroupedValues(DateUtils.TruncateField field) private HashMap<Long, ArrayList<Double>> getGroupedValues(DateUtils.TruncateField field)
{ {
HashMap<Long, ArrayList<Long>> groups = new HashMap<>(); HashMap<Long, ArrayList<Double>> groups = new HashMap<>();
for (Score s : this) for (Score s : this)
{ {
@@ -296,26 +298,26 @@ public abstract class ScoreList implements Iterable<Score>
if (!groups.containsKey(groupTimestamp)) if (!groups.containsKey(groupTimestamp))
groups.put(groupTimestamp, new ArrayList<>()); groups.put(groupTimestamp, new ArrayList<>());
groups.get(groupTimestamp).add((long) s.getValue()); groups.get(groupTimestamp).add(s.getValue());
} }
return groups; return groups;
} }
@NonNull @NonNull
private List<Score> groupsToAvgScores(HashMap<Long, ArrayList<Long>> groups) private List<Score> groupsToAvgScores(HashMap<Long, ArrayList<Double>> groups)
{ {
List<Score> scores = new LinkedList<>(); List<Score> scores = new LinkedList<>();
for (Long timestamp : groups.keySet()) for (Long timestamp : groups.keySet())
{ {
long meanValue = 0L; double meanValue = 0.0;
ArrayList<Long> groupValues = groups.get(timestamp); ArrayList<Double> groupValues = groups.get(timestamp);
for (Long v : groupValues) meanValue += v; for (Double v : groupValues) meanValue += v;
meanValue /= groupValues.size(); meanValue /= groupValues.size();
scores.add(new Score(timestamp, (int) meanValue)); scores.add(new Score(timestamp, meanValue));
} }
return scores; return scores;

View File

@@ -53,7 +53,7 @@ public class CheckmarkWidget extends BaseWidget
CheckmarkWidgetView view = (CheckmarkWidgetView) v; CheckmarkWidgetView view = (CheckmarkWidgetView) v;
int color = ColorUtils.getColor(getContext(), habit.getColor()); int color = ColorUtils.getColor(getContext(), habit.getColor());
double score = habit.getScores().getTodayValue(); double score = habit.getScores().getTodayValue();
float percentage = (float) score / Score.MAX_VALUE; float percentage = (float) score;
int checkmark = habit.getCheckmarks().getTodayValue(); int checkmark = habit.getCheckmarks().getTodayValue();
view.setPercentage(percentage); view.setPercentage(percentage);

View File

@@ -87,7 +87,7 @@ public class EditHabitCommandTest extends BaseUnitTest
command.execute(); command.execute();
assertThat(habit.getName(), equalTo("modified")); assertThat(habit.getName(), equalTo("modified"));
assertThat(habit.getScores().getTodayValue(), assertThat(habit.getScores().getTodayValue(),
greaterThan(originalScore)); lessThan(originalScore));
command.undo(); command.undo();
assertThat(habit.getName(), equalTo("original")); assertThat(habit.getName(), equalTo("original"));
@@ -96,6 +96,6 @@ public class EditHabitCommandTest extends BaseUnitTest
command.execute(); command.execute();
assertThat(habit.getName(), equalTo("modified")); assertThat(habit.getName(), equalTo("modified"));
assertThat(habit.getScores().getTodayValue(), assertThat(habit.getScores().getTodayValue(),
greaterThan(originalScore)); lessThan(originalScore));
} }
} }

View File

@@ -28,9 +28,12 @@ import java.util.*;
import static org.hamcrest.CoreMatchers.*; import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.*; import static org.hamcrest.MatcherAssert.*;
import static org.hamcrest.number.IsCloseTo.closeTo;
public class ScoreListTest extends BaseUnitTest public class ScoreListTest extends BaseUnitTest
{ {
private static final double E = 1e-6;
private Habit habit; private Habit habit;
@Override @Override
@@ -47,42 +50,39 @@ public class ScoreListTest extends BaseUnitTest
toggleRepetitions(0, 20); toggleRepetitions(0, 20);
double expectedValues[] = { double expectedValues[] = {
12629351, 0.655747,
12266245, 0.636894,
11883254, 0.617008,
11479288, 0.596033,
11053198, 0.573910,
10603773, 0.550574,
10129735, 0.525961,
9629735, 0.500000,
9102352, 0.472617,
8546087, 0.443734,
7959357, 0.413270,
7340494, 0.381137,
6687738, 0.347244,
5999234, 0.311495,
5273023, 0.273788,
4507040, 0.234017,
3699107, 0.192067,
2846927, 0.147820,
1948077, 0.101149,
1000000 0.051922,
}; };
double actualValues[] = new double[expectedValues.length];
int i = 0; int i = 0;
for (Score s : habit.getScores()) for (Score s : habit.getScores())
actualValues[i++] = s.getValue(); assertThat(s.getValue(), closeTo(expectedValues[i++], E));
assertThat(actualValues, equalTo(expectedValues));
} }
@Test @Test
public void test_getTodayValue() public void test_getTodayValue()
{ {
toggleRepetitions(0, 20); toggleRepetitions(0, 20);
assertThat(habit.getScores().getTodayValue(), equalTo(12629351.0)); double actual = habit.getScores().getTodayValue();
assertThat(actual, closeTo(0.655747, E));
} }
@Test @Test
@@ -91,36 +91,36 @@ public class ScoreListTest extends BaseUnitTest
toggleRepetitions(0, 20); toggleRepetitions(0, 20);
double expectedValues[] = { double expectedValues[] = {
12629351, 0.655747,
12266245, 0.636894,
11883254, 0.617008,
11479288, 0.596033,
11053198, 0.573910,
10603773, 0.550574,
10129735, 0.525961,
9629735, 0.500000,
9102352, 0.472617,
8546087, 0.443734,
7959357, 0.413270,
7340494, 0.381137,
6687738, 0.347244,
5999234, 0.311495,
5273023, 0.273788,
4507040, 0.234017,
3699107, 0.192067,
2846927, 0.147820,
1948077, 0.101149,
1000000, 0.051922,
0, 0.000000,
0, 0.000000,
0 0.000000
}; };
ScoreList scores = habit.getScores(); ScoreList scores = habit.getScores();
long current = DateUtils.getStartOfToday(); long current = DateUtils.getStartOfToday();
for (double expectedValue : expectedValues) for (double expectedValue : expectedValues)
{ {
assertThat(scores.getValue(current), equalTo(expectedValue)); assertThat(scores.getValue(current), closeTo(expectedValue, E));
current -= DateUtils.millisecondsInOneDay; current -= DateUtils.millisecondsInOneDay;
} }
} }
@@ -133,23 +133,23 @@ public class ScoreListTest extends BaseUnitTest
habit.getScores().groupBy(DateUtils.TruncateField.MONTH); habit.getScores().groupBy(DateUtils.TruncateField.MONTH);
assertThat(list.size(), equalTo(5)); assertThat(list.size(), equalTo(5));
assertThat(list.get(0).getValue(), equalTo(14634077.0)); assertThat(list.get(0).getValue(), closeTo(0.549096, E));
assertThat(list.get(1).getValue(), equalTo(12969133.0)); assertThat(list.get(1).getValue(), closeTo(0.480098, E));
assertThat(list.get(2).getValue(), equalTo(10595391.0)); assertThat(list.get(2).getValue(), closeTo(0.377885, E));
} }
@Test @Test
public void test_invalidateNewerThan() public void test_invalidateNewerThan()
{ {
assertThat(habit.getScores().getTodayValue(), equalTo(0.0)); assertThat(habit.getScores().getTodayValue(), closeTo(0.0, E));
toggleRepetitions(0, 2); toggleRepetitions(0, 2);
assertThat(habit.getScores().getTodayValue(), equalTo(1948077.0)); assertThat(habit.getScores().getTodayValue(), closeTo(0.101149, E));
habit.setFrequency(new Frequency(1, 2)); habit.setFrequency(new Frequency(1, 2));
habit.getScores().invalidateNewerThan(0); habit.getScores().invalidateNewerThan(0);
assertThat(habit.getScores().getTodayValue(), equalTo(1974654.0)); assertThat(habit.getScores().getTodayValue(), closeTo(0.051922, E));
} }
@Test @Test
@@ -157,16 +157,16 @@ public class ScoreListTest extends BaseUnitTest
{ {
Habit habit = fixtures.createShortHabit(); Habit habit = fixtures.createShortHabit();
String expectedCSV = "2015-01-25,0.2649\n" + String expectedCSV = "2015-01-25,0.2372\n" +
"2015-01-24,0.2205\n" + "2015-01-24,0.2096\n" +
"2015-01-23,0.2283\n" + "2015-01-23,0.2172\n" +
"2015-01-22,0.2364\n" + "2015-01-22,0.1889\n" +
"2015-01-21,0.1909\n" + "2015-01-21,0.1595\n" +
"2015-01-20,0.1439\n" + "2015-01-20,0.1291\n" +
"2015-01-19,0.0952\n" + "2015-01-19,0.0976\n" +
"2015-01-18,0.0986\n" + "2015-01-18,0.1011\n" +
"2015-01-17,0.1021\n" + "2015-01-17,0.0686\n" +
"2015-01-16,0.0519\n"; "2015-01-16,0.0349\n";
StringWriter writer = new StringWriter(); StringWriter writer = new StringWriter();
habit.getScores().writeCSV(writer); habit.getScores().writeCSV(writer);
@@ -186,13 +186,16 @@ public class ScoreListTest extends BaseUnitTest
long to = today - 2 * day; long to = today - 2 * day;
double[] expected = { double[] expected = {
11883254, 0.617008,
11479288, 0.596033,
11053198, 0.573909,
}; };
double[] actual = habit.getScores().getValues(from, to); double[] actual = habit.getScores().getValues(from, to);
assertThat(actual, equalTo(expected)); assertThat(actual.length, equalTo(expected.length));
for(int i = 0; i < actual.length; i++)
assertThat(actual[i], closeTo(expected[i], E));
} }
private void toggleRepetitions(final int from, final int to) private void toggleRepetitions(final int from, final int to)

View File

@@ -19,15 +19,17 @@
package org.isoron.uhabits.models; package org.isoron.uhabits.models;
import org.isoron.uhabits.BaseUnitTest; import org.isoron.uhabits.*;
import org.junit.Before; import org.junit.*;
import org.junit.Test;
import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.number.IsCloseTo.*;
import static org.junit.Assert.assertThat; import static org.isoron.uhabits.models.Score.*;
import static org.junit.Assert.*;
public class ScoreTest extends BaseUnitTest public class ScoreTest extends BaseUnitTest
{ {
private static final double E = 1e-6;
@Override @Override
@Before @Before
public void setUp() public void setUp()
@@ -38,46 +40,30 @@ public class ScoreTest extends BaseUnitTest
@Test @Test
public void test_compute_withDailyHabit() public void test_compute_withDailyHabit()
{ {
int checkmark = Checkmark.UNCHECKED; int check = 1;
assertThat(Score.compute(1, 0, checkmark), equalTo(0)); double freq = 1.0;
assertThat(Score.compute(1, 5000000, checkmark), equalTo(4740387)); assertThat(compute(freq, 0, check), closeTo(0.051922, E));
assertThat(Score.compute(1, 10000000, checkmark), equalTo(9480775)); assertThat(compute(freq, 0.5, check), closeTo(0.525961, E));
assertThat(Score.compute(1, Score.MAX_VALUE, checkmark), assertThat(compute(freq, 0.75, check), closeTo(0.762981, E));
equalTo(18259478));
checkmark = Checkmark.CHECKED_IMPLICITLY; check = 0;
assertThat(Score.compute(1, 0, checkmark), equalTo(0)); assertThat(compute(freq, 0, check), closeTo(0, E));
assertThat(Score.compute(1, 5000000, checkmark), equalTo(4740387)); assertThat(compute(freq, 0.5, check), closeTo(0.474039, E));
assertThat(Score.compute(1, 10000000, checkmark), equalTo(9480775)); assertThat(compute(freq, 0.75, check), closeTo(0.711058, E));
assertThat(Score.compute(1, Score.MAX_VALUE, checkmark),
equalTo(18259478));
checkmark = Checkmark.CHECKED_EXPLICITLY;
assertThat(Score.compute(1, 0, checkmark), equalTo(1000000));
assertThat(Score.compute(1, 5000000, checkmark), equalTo(5740387));
assertThat(Score.compute(1, 10000000, checkmark), equalTo(10480775));
assertThat(Score.compute(1, Score.MAX_VALUE, checkmark),
equalTo(Score.MAX_VALUE));
} }
@Test @Test
public void test_compute_withNonDailyHabit() public void test_compute_withNonDailyHabit()
{ {
int checkmark = Checkmark.CHECKED_EXPLICITLY; int check = 1;
assertThat(Score.compute(1 / 3.0, 0, checkmark), equalTo(1000000)); double freq = 1 / 3.0;
assertThat(Score.compute(1 / 3.0, 5000000, checkmark), assertThat(compute(freq, 0, check), closeTo(0.017616, E));
equalTo(5916180)); assertThat(compute(freq, 0.5, check), closeTo(0.508808, E));
assertThat(Score.compute(1 / 3.0, 10000000, checkmark), assertThat(compute(freq, 0.75, check), closeTo(0.754404, E));
equalTo(10832360));
assertThat(Score.compute(1 / 3.0, Score.MAX_VALUE, checkmark),
equalTo(Score.MAX_VALUE));
assertThat(Score.compute(1 / 7.0, 0, checkmark), equalTo(1000000)); check = 0;
assertThat(Score.compute(1 / 7.0, 5000000, checkmark), assertThat(compute(freq, 0, check), closeTo(0.0, E));
equalTo(5964398)); assertThat(compute(freq, 0.5, check), closeTo(0.491192, E));
assertThat(Score.compute(1 / 7.0, 10000000, checkmark), assertThat(compute(freq, 0.75, check), closeTo(0.736788, E));
equalTo(10928796));
assertThat(Score.compute(1 / 7.0, Score.MAX_VALUE, checkmark),
equalTo(Score.MAX_VALUE));
} }
} }