1 package tim.prune.function.estimate;
3 import java.awt.BorderLayout;
4 import java.awt.Component;
5 import java.awt.FlowLayout;
6 import java.awt.event.ActionEvent;
7 import java.awt.event.ActionListener;
8 import java.awt.event.AdjustmentEvent;
9 import java.awt.event.AdjustmentListener;
10 import java.awt.event.KeyAdapter;
11 import java.awt.event.KeyEvent;
12 import java.util.ArrayList;
14 import javax.swing.BorderFactory;
15 import javax.swing.Box;
16 import javax.swing.BoxLayout;
17 import javax.swing.JButton;
18 import javax.swing.JDialog;
19 import javax.swing.JLabel;
20 import javax.swing.JPanel;
21 import javax.swing.JScrollBar;
24 import tim.prune.GenericFunction;
25 import tim.prune.I18nManager;
26 import tim.prune.config.Config;
27 import tim.prune.data.DataPoint;
28 import tim.prune.data.Distance;
29 import tim.prune.data.RangeStats;
30 import tim.prune.data.Track;
31 import tim.prune.data.Unit;
32 import tim.prune.data.UnitSetLibrary;
33 import tim.prune.function.estimate.jama.Matrix;
34 import tim.prune.gui.ProgressDialog;
37 * Function to learn the estimation parameters from the current track
39 public class LearnParameters extends GenericFunction implements Runnable
41 /** Progress dialog */
42 ProgressDialog _progress = null;
44 JDialog _dialog = null;
45 /** Calculated parameters */
46 private ParametersPanel _calculatedParamPanel = null;
47 private EstimationParameters _calculatedParams = null;
48 /** Slider for weighted average */
49 private JScrollBar _weightSlider = null;
50 /** Label to describe position of slider */
51 private JLabel _sliderDescLabel = null;
52 /** Combined parameters */
53 private ParametersPanel _combinedParamPanel = null;
55 private JButton _combineButton = null;
59 * Inner class used to hold the results of the matrix solving
61 static class MatrixResults
63 public EstimationParameters _parameters = null;
64 public double _averageErrorPc = 0.0; // percentage
70 * @param inApp App object
72 public LearnParameters(App inApp)
77 /** @return key for function name */
78 public String getNameKey() {
79 return "function.learnestimationparams";
88 if (_progress == null) {
89 _progress = new ProgressDialog(_parentFrame, getNameKey());
92 // Start new thread for the calculations
93 new Thread(this).start();
97 * Run method in separate thread
101 _progress.setMaximum(100);
102 // Go through the track and collect the range stats for each sample
103 ArrayList<RangeStats> statsList = new ArrayList<RangeStats>(20);
104 Track track = _app.getTrackInfo().getTrack();
105 final int numPoints = track.getNumPoints();
106 final int sampleSize = numPoints / 30;
107 int prevStartIndex = -1;
108 for (int i=0; i<30; i++)
110 int startIndex = i * sampleSize;
111 RangeStats stats = getRangeStats(track, startIndex, startIndex + sampleSize, prevStartIndex);
112 if (stats != null && stats.getMovingDistanceKilometres() > 1.0
113 && !stats.getTimestampsIncomplete() && !stats.getTimestampsOutOfSequence()
114 && stats.getTotalDurationInSeconds() > 100
115 && stats.getStartIndex() > prevStartIndex)
117 // System.out.println("Got stats for " + stats.getStartIndex() + " to " + stats.getEndIndex());
118 statsList.add(stats);
119 prevStartIndex = stats.getStartIndex();
121 _progress.setValue(i);
124 // Check if we've got enough samples
125 // System.out.println("Got a total of " + statsList.size() + " samples");
126 if (statsList.size() < 10)
129 // Show error message, not enough samples
130 _app.showErrorMessage(getNameKey(), "error.learnestimationparams.failed");
133 // Loop around, solving the matrices and removing the highest-error sample
134 MatrixResults results = reduceSamples(statsList);
138 _app.showErrorMessage(getNameKey(), "error.learnestimationparams.failed");
144 // Create the dialog if necessary
147 _dialog = new JDialog(_parentFrame, I18nManager.getText(getNameKey()), true);
148 _dialog.setLocationRelativeTo(_parentFrame);
149 // Create Gui and show it
150 _dialog.getContentPane().add(makeDialogComponents());
154 // Populate the values in the dialog
155 populateCalculatedValues(results);
156 updateCombinedLabels(calculateCombinedParameters());
157 _dialog.setVisible(true);
162 * Make the dialog components
163 * @return the GUI components for the dialog
165 private Component makeDialogComponents()
167 JPanel dialogPanel = new JPanel();
168 dialogPanel.setLayout(new BorderLayout());
170 // main panel with a box layout
171 JPanel mainPanel = new JPanel();
172 mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
174 JLabel introLabel = new JLabel(I18nManager.getText("dialog.learnestimationparams.intro") + ":");
175 introLabel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
176 introLabel.setAlignmentX(Component.LEFT_ALIGNMENT);
177 mainPanel.add(introLabel);
179 // Panel for the calculated results
180 _calculatedParamPanel = new ParametersPanel("dialog.estimatetime.results", true);
181 _calculatedParamPanel.setAlignmentX(Component.LEFT_ALIGNMENT);
182 mainPanel.add(_calculatedParamPanel);
183 mainPanel.add(Box.createVerticalStrut(14));
185 mainPanel.add(new JLabel(I18nManager.getText("dialog.learnestimationparams.combine") + ":"));
186 mainPanel.add(Box.createVerticalStrut(4));
187 _weightSlider = new JScrollBar(JScrollBar.HORIZONTAL, 5, 1, 0, 11);
188 _weightSlider.addAdjustmentListener(new AdjustmentListener() {
189 public void adjustmentValueChanged(AdjustmentEvent inEvent)
191 if (!inEvent.getValueIsAdjusting()) {
192 updateCombinedLabels(calculateCombinedParameters());
196 mainPanel.add(_weightSlider);
197 _sliderDescLabel = new JLabel(" ");
198 _sliderDescLabel.setAlignmentX(Component.LEFT_ALIGNMENT);
199 mainPanel.add(_sliderDescLabel);
200 mainPanel.add(Box.createVerticalStrut(12));
203 _combinedParamPanel = new ParametersPanel("dialog.learnestimationparams.combinedresults");
204 _combinedParamPanel.setAlignmentX(Component.LEFT_ALIGNMENT);
205 mainPanel.add(_combinedParamPanel);
207 dialogPanel.add(mainPanel, BorderLayout.NORTH);
209 // button panel at bottom
210 JPanel buttonPanel = new JPanel();
211 buttonPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));
214 _combineButton = new JButton(I18nManager.getText("button.combine"));
215 _combineButton.addActionListener(new ActionListener() {
216 public void actionPerformed(ActionEvent arg0) {
220 buttonPanel.add(_combineButton);
223 JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
224 cancelButton.addActionListener(new ActionListener() {
225 public void actionPerformed(ActionEvent e) {
229 KeyAdapter escapeListener = new KeyAdapter() {
230 public void keyPressed(KeyEvent inE) {
231 if (inE.getKeyCode() == KeyEvent.VK_ESCAPE) {_dialog.dispose();}
234 _combineButton.addKeyListener(escapeListener);
235 cancelButton.addKeyListener(escapeListener);
236 buttonPanel.add(cancelButton);
237 dialogPanel.add(buttonPanel, BorderLayout.SOUTH);
242 * Construct a rangestats object for the selected range
243 * @param inTrack track object
244 * @param inStartIndex start index
245 * @param inEndIndex end index
246 * @param inPreviousStartIndex the previously used start index, or -1
247 * @return range stats object or null if required information missing from this bit of the track
249 private RangeStats getRangeStats(Track inTrack, int inStartIndex, int inEndIndex, int inPreviousStartIndex)
252 if (inTrack == null || inStartIndex < 0 || inEndIndex <= inStartIndex || inStartIndex > inTrack.getNumPoints()) {
255 final int numPoints = inTrack.getNumPoints();
256 int start = inStartIndex;
258 // Search forward until a decent track point found for the start
259 DataPoint p = inTrack.getPoint(start);
260 while (start < numPoints && (p == null || p.isWaypoint() || !p.hasTimestamp() || !p.hasAltitude()))
263 p = inTrack.getPoint(start);
265 if (inPreviousStartIndex >= 0 && start <= (inPreviousStartIndex + 10) // overlapping too much with previous range
266 || (start >= (numPoints - 10))) // starting too late in the track
271 // Search forward (counting the radians) until a decent end point found
272 double movingRads = 0.0;
273 final double minimumRads = Distance.convertDistanceToRadians(1.0, UnitSetLibrary.UNITS_KILOMETRES);
274 DataPoint prevPoint = inTrack.getPoint(start);
275 int endIndex = start;
276 boolean shouldStop = false;
280 p = inTrack.getPoint(endIndex);
281 if (p != null && !p.isWaypoint())
283 if (!p.hasAltitude() || !p.hasTimestamp()) {return null;} // abort if no time/altitude
284 if (prevPoint != null && !p.getSegmentStart()) {
285 movingRads += DataPoint.calculateRadiansBetween(prevPoint, p);
289 if (endIndex >= numPoints) {
290 shouldStop = true; // reached the end of the track
292 else if (movingRads >= minimumRads && endIndex >= inEndIndex) {
293 shouldStop = true; // got at least a kilometre
298 // Check moving distance
299 if (movingRads >= minimumRads) {
300 return new RangeStats(inTrack, start, endIndex);
306 * Build an A matrix for the given list of RangeStats objects
307 * @param inStatsList list of (non-null) RangeStats objects
308 * @return A matrix with n rows and 5 columns
310 private static Matrix buildAMatrix(ArrayList<RangeStats> inStatsList)
312 final Unit METRES = UnitSetLibrary.UNITS_METRES;
313 Matrix result = new Matrix(inStatsList.size(), 5);
315 for (RangeStats stats : inStatsList)
317 result.setValue(row, 0, stats.getMovingDistanceKilometres());
318 result.setValue(row, 1, stats.getGentleAltitudeRange().getClimb(METRES));
319 result.setValue(row, 2, stats.getSteepAltitudeRange().getClimb(METRES));
320 result.setValue(row, 3, stats.getGentleAltitudeRange().getDescent(METRES));
321 result.setValue(row, 4, stats.getSteepAltitudeRange().getDescent(METRES));
328 * Build a B matrix containing the observations (moving times)
329 * @param inStatsList list of (non-null) RangeStats objects
330 * @return B matrix with single column of n rows
332 private static Matrix buildBMatrix(ArrayList<RangeStats> inStatsList)
334 Matrix result = new Matrix(inStatsList.size(), 1);
336 for (RangeStats stats : inStatsList)
338 result.setValue(row, 0, stats.getMovingDurationInSeconds() / 60.0); // convert seconds to minutes
345 * Look for the maximum absolute value in the given column matrix
346 * @param inMatrix matrix with only one column
347 * @return row index of cell with greatest absolute value, or -1 if not valid
349 private static int getIndexOfMaxValue(Matrix inMatrix)
351 if (inMatrix == null || inMatrix.getNumColumns() > 1) {
355 double currValue = 0.0, maxValue = 0.0;
356 // Loop over the first column looking for the maximum absolute value
357 for (int i=0; i<inMatrix.getNumRows(); i++)
359 currValue = Math.abs(inMatrix.get(i, 0));
360 if (currValue > maxValue)
362 maxValue = currValue;
370 * See if the given set of samples is sufficient for getting a descent solution (at least 3 nonzero values)
371 * @param inRangeSet list of RangeStats objects
372 * @param inRowToIgnore row index to ignore, or -1 to use them all
373 * @return true if the samples look ok
375 private static boolean isRangeSetSufficient(ArrayList<RangeStats> inRangeSet, int inRowToIgnore)
377 int numGC = 0, numSC = 0, numGD = 0, numSD = 0; // number of samples with gentle/steep climb/descent values > 0
378 final Unit METRES = UnitSetLibrary.UNITS_METRES;
380 for (RangeStats stats : inRangeSet)
382 if (i != inRowToIgnore)
384 if (stats.getGentleAltitudeRange().getClimb(METRES) > 0) {numGC++;}
385 if (stats.getSteepAltitudeRange().getClimb(METRES) > 0) {numSC++;}
386 if (stats.getGentleAltitudeRange().getDescent(METRES) > 0) {numGD++;}
387 if (stats.getSteepAltitudeRange().getDescent(METRES) > 0) {numSD++;}
391 return numGC > 3 && numSC > 3 && numGD > 3 && numSD > 3;
395 * Reduce the number of samples in the given list by eliminating the ones with highest errors
396 * @param inStatsList list of stats
397 * @return results in an object
399 private MatrixResults reduceSamples(ArrayList<RangeStats> inStatsList)
401 int statsIndexToRemove = -1;
402 Matrix answer = null;
403 boolean finished = false;
404 double averageErrorPc = 0.0;
407 // Remove the marked stats object, if any
408 if (statsIndexToRemove >= 0) {
409 inStatsList.remove(statsIndexToRemove);
412 // Build up the matrices
413 Matrix A = buildAMatrix(inStatsList);
414 Matrix B = buildBMatrix(inStatsList);
415 // System.out.println("Times in minutes are:\n" + B.toString());
417 // Solve (if possible)
421 // System.out.println("Solved matrix with " + A.getNumRows() + " rows:\n" + answer.toString());
422 // Work out the percentage error for each estimate
423 Matrix estimates = A.times(answer);
424 Matrix errors = estimates.minus(B).divideEach(B);
425 // System.out.println("Errors: " + errors.toString());
426 averageErrorPc = errors.getAverageAbsValue();
427 // find biggest percentage error, remove it from list
428 statsIndexToRemove = getIndexOfMaxValue(errors);
429 if (statsIndexToRemove < 0)
431 System.err.println("Something wrong - index is " + statsIndexToRemove);
432 throw new Exception();
434 // Check whether removing this element would make the range set insufficient
435 finished = inStatsList.size() <= 25 || !isRangeSetSufficient(inStatsList, statsIndexToRemove);
439 // Couldn't solve at all
440 System.out.println("Failed to reduce: " + e.getClass().getName() + " - " + e.getMessage());
443 _progress.setValue(20 + 80 * (30 - inStatsList.size())/5); // Counting from 30 to 25
445 // Copy results to an EstimationParameters object
446 MatrixResults result = new MatrixResults();
447 result._parameters = new EstimationParameters();
448 result._parameters.populateWithMetrics(answer.get(0, 0) * 5, // convert from 1km to 5km
449 answer.get(1, 0) * 100.0, answer.get(2, 0) * 100.0, // convert from m to 100m
450 answer.get(3, 0) * 100.0, answer.get(4, 0) * 100.0);
451 result._averageErrorPc = averageErrorPc;
457 * Populate the dialog's labels with the calculated values
458 * @param inResults results of the calculations
460 private void populateCalculatedValues(MatrixResults inResults)
462 if (inResults == null || inResults._parameters == null)
464 _calculatedParams = null;
465 _calculatedParamPanel.updateParameters(null, 0.0);
469 _calculatedParams = inResults._parameters;
470 _calculatedParamPanel.updateParameters(_calculatedParams, inResults._averageErrorPc);
475 * Combine the calculated parameters with the existing ones
476 * according to the value of the slider
477 * @return combined parameters
479 private EstimationParameters calculateCombinedParameters()
481 final double fraction1 = 1 - 0.1 * _weightSlider.getValue(); // slider left = value 0 = fraction 1 = keep current
482 EstimationParameters oldParams = new EstimationParameters(Config.getConfigString(Config.KEY_ESTIMATION_PARAMS));
483 return oldParams.combine(_calculatedParams, fraction1);
487 * Update the labels to show the combined parameters
488 * @param inCombinedParams combined estimation parameters
490 private void updateCombinedLabels(EstimationParameters inCombinedParams)
492 // Update the slider description label
493 String sliderDesc = null;
494 final int sliderVal = _weightSlider.getValue();
497 case 0: sliderDesc = I18nManager.getText("dialog.learnestimationparams.weight.100pccurrent"); break;
498 case 5: sliderDesc = I18nManager.getText("dialog.learnestimationparams.weight.50pc"); break;
499 case 10: sliderDesc = I18nManager.getText("dialog.learnestimationparams.weight.100pccalculated"); break;
501 final int currTenths = 10 - sliderVal, calcTenths = sliderVal;
502 sliderDesc = "" + currTenths + "0% " + I18nManager.getText("dialog.learnestimationparams.weight.current")
503 + " + " + calcTenths + "0% " + I18nManager.getText("dialog.learnestimationparams.weight.calculated");
505 _sliderDescLabel.setText(sliderDesc);
506 // And update all the combined params labels
507 _combinedParamPanel.updateParameters(inCombinedParams);
508 _combineButton.setEnabled(sliderVal > 0);
512 * React to the combine button, by saving the combined parameters in the config
514 private void combineAndFinish()
516 EstimationParameters params = calculateCombinedParameters();
517 Config.setConfigString(Config.KEY_ESTIMATION_PARAMS, params.toConfigString());