// Analyze.java package net.sf.gogui.tools.statistics; import java.awt.Color; import java.io.File; import java.io.FileInputStream; import java.io.FileWriter; import java.io.InputStream; import java.io.IOException; import java.io.PrintStream; import java.util.ArrayList; import java.text.DecimalFormat; import static net.sf.gogui.go.GoColor.BLACK; import static net.sf.gogui.go.GoColor.WHITE; import net.sf.gogui.game.GameInfo; import net.sf.gogui.game.GameTree; import net.sf.gogui.game.StringInfo; import net.sf.gogui.game.StringInfoColor; import net.sf.gogui.sgf.SgfReader; import net.sf.gogui.util.ErrorMessage; import net.sf.gogui.util.FileUtil; import net.sf.gogui.util.Histogram; import net.sf.gogui.util.HtmlUtil; import net.sf.gogui.util.StringUtil; import net.sf.gogui.util.Table; import net.sf.gogui.util.TableUtil; /** Produce HTML reports from the table generated by Statistics. */ public class Analyze { public Analyze(String fileName, String output, int precision) throws Exception { if (output.equals("")) m_output = FileUtil.removeExtension(new File(fileName), "dat"); else if (new File(output).isDirectory()) { File name = new File((new File(fileName)).getName()); m_output = output + File.separator + FileUtil.removeExtension(name, "dat"); } else m_output = output; m_precision = precision; m_table = new Table(); m_table.read(new File(fileName)); if (m_table.getNumberColumns() < 2 || ! m_table.getColumnTitle(0).equals("File") || ! m_table.getColumnTitle(1).equals("Move")) throw new ErrorMessage("Invalid table format"); m_commands = new ArrayList<String>(); for (int i = 2; i < m_table.getNumberColumns(); ++i) m_commands.add(m_table.getColumnTitle(i)); m_commandStatistics = new ArrayList<CommandStatistics>(m_commands.size()); File file = new File(m_output + ".html"); initGameData(); findGameGlobalCommands(); PrintStream out = new PrintStream(file); startHtml(out, "Statistics Summary"); startInfo(out, "Statistics Summary"); writeInfo(out); endInfo(out); out.print("<table border=\"0\">\n" + "<tr><td>\n"); writePlot(out, "<small>positions</small>", getCountFile().getName(), "<a href=\"" + getCountDataFile().getName() + "\"><small>data</small></a>"); out.print("</td></tr>\n"); for (int i = 0; i < m_commands.size(); ++i) { CommandStatistics commandStatistics = computeCommandStatistics(i); m_commandStatistics.add(commandStatistics); if (commandStatistics.getCount() > 0 && ! commandStatistics.m_isBeginCommand) { String command = getCommand(i); Table table = commandStatistics.m_tableAtMove; Plot plot = generatePlotMove(getImgWidth(m_maxMove), getColor(command)); plot.setFormatY(commandStatistics.m_format); File pngFile = getAvgPlotFile(i); File dataFile = getAvgDataFile(i); plot.setPlotStyleNoLines(); plot.plot(pngFile, table, "Move", "Mean", "Error"); FileWriter writer = new FileWriter(dataFile); try { table.save(writer, false); } finally { writer.close(); } out.print("<tr><td>\n"); writePlot(out, getCommandLink(i), pngFile.getName(), "<a href=\"" + dataFile.getName() + "\"><small>data</small></a>"); out.print("</td></tr>\n"); } } out.print("</table>\n" + "<hr>\n" + "<table border=\"0\" cellpadding=\"0\">\n" + "<tr>\n"); int n = 0; for (int i = 0; i < m_commands.size(); ++i) { CommandStatistics commandStatistics = getCommandStatistics(i); if (commandStatistics.getCount() == 0) continue; out.print("<td valign=\"bottom\" bgcolor=\"" + COLOR_HEADER + "\">\n"); writePlot(out, getCommandLink(i), getHistoFile(i).getName(), ""); out.print("</td>\n"); ++n; if (n % 5 == 0) out.print("</tr><tr>\n"); } out.print("</tr>\n" + "</table>\n" + "<hr>\n"); writeCommandsTable(out); out.print("<hr>\n"); writeGameTable(out); finishHtml(out); out.close(); } private static final Color[] PLOT_COLOR = { Color.decode("#ff0000"), Color.decode("#ff9800"), Color.decode("#009800"), Color.decode("#00c0c0"), Color.decode("#0000ff"), Color.decode("#980098") }; /** Command having at most one result per game. */ private static class GameGlobalCommand { public GameGlobalCommand(String name, ArrayList<String> results) { m_name = name; m_results = results; initAllEmpty(); } public boolean allEmpty() { return m_allEmpty; } public String getName() { return m_name; } public String getResult(int game) { return m_results.get(game); } private boolean m_allEmpty; private final String m_name; /** Results per game. */ private final ArrayList<String> m_results; private void initAllEmpty() { m_allEmpty = false; for (int game = 0; game < m_results.size(); ++game) { String result = getResult(game); if (! StringUtil.isEmpty(result)) return; } m_allEmpty = true; } } private static class GameData { public String m_file; public String m_name; public int m_finalPosition; public int m_numberPositions; } private static final int IMAGE_HEIGHT = 100; private int m_maxMove; private int m_movePrintInterval; private final int m_precision; private static final String COLOR_HEADER = "#b5c8f0"; private static final String COLOR_INFO = "#e0e0e0"; private final String m_output; private final Table m_table; private Table m_tableFinal; private final ArrayList<CommandStatistics> m_commandStatistics; private final ArrayList<String> m_commands; private ArrayList<GameGlobalCommand> m_gameGlobalCommands; private ArrayList<GameData> m_gameData; private void endInfo(PrintStream out) { out.print("</table></td></tr>\n" + "</table>\n" + "<hr>\n"); } private void findGameGlobalCommands() throws Table.InvalidLocation { m_gameGlobalCommands = new ArrayList<GameGlobalCommand>(); for (int i = 0; i < m_commands.size(); ++i) { String command = getCommand(i); boolean isGameGlobal = true; ArrayList<String> gameResult = new ArrayList<String>(); for (int j = 0; j < m_gameData.size(); ++j) { GameData data = m_gameData.get(j); Table table = TableUtil.select(m_table, "File", data.m_file, command); ArrayList<String> notEmpty = TableUtil.getColumnNotEmpty(table, command); if (notEmpty.size() > 1) { isGameGlobal = false; break; } else if (notEmpty.size() == 1) gameResult.add(notEmpty.get(0)); else gameResult.add(""); } if (isGameGlobal) { GameGlobalCommand gameGlobalCommand = new GameGlobalCommand(command, gameResult); m_gameGlobalCommands.add(gameGlobalCommand); } } } private void finishHtml(PrintStream out) { out.print(HtmlUtil.getFooter("gogui-statistics") + "</body>\n" + "</html>\n"); } private File getAvgDataFile(int commandIndex) { return new File(m_output + ".command-" + commandIndex + ".avg.dat"); } private File getAvgPlotFile(int commandIndex) { return new File(m_output + ".command-" + commandIndex + ".avg.png"); } private String getCommand(int index) { return m_commands.get(index); } private File getCommandFile(int commandIndex) { return new File(m_output + ".command-" + commandIndex + ".html"); } private CommandStatistics getCommandStatistics(int commandIndex) { return m_commandStatistics.get(commandIndex); } private File getCountFile() { return new File(m_output + ".count.png"); } private File getCountDataFile() { return new File(m_output + ".count.dat"); } private File getGameFile(int gameIndex) { return new File(m_output + ".game-" + gameIndex + ".html"); } private GameGlobalCommand getGameGlobalCommand(int index) { return m_gameGlobalCommands.get(index); } private File getHistoFile(int commandIndex) { return new File(m_output + ".command-" + commandIndex + ".histo.png"); } private File getHistoFile(int commandIndex, int moveIntervalIndex) { return new File(m_output + ".command-" + commandIndex + ".interval-" + moveIntervalIndex + ".histo.png"); } private File getHistoFinalFile(int commandIndex) { return new File(m_output + ".command-" + commandIndex + ".final.png"); } private File getPlotFile(int gameIndex, int commandIndex) { return new File(m_output + ".game-" + gameIndex + ".command-" + commandIndex + ".png"); } private String getCommandLink(int commandIndex) { String link = "<small>" + getCommand(commandIndex) + "</small>"; CommandStatistics statistics = getCommandStatistics(commandIndex); if (statistics.getCount() == 0 || statistics.m_isBeginCommand) return link; return "<a href=\"" + getCommandFile(commandIndex).getName() + "\">" + link + "</a>"; } private CommandStatistics computeCommandStatistics(int index) throws Exception { String command = getCommand(index); return new CommandStatistics(command, m_table, m_tableFinal, getHistoFile(index), getHistoFinalFile(index), getColor(command), m_precision); } private Plot generatePlotMove(int width, Color color) { Plot plot = new Plot(width, IMAGE_HEIGHT, color, m_precision); plot.setSolidLineInterval(10); plot.setXMin(0); plot.setXMax(m_maxMove); plot.setXTics(5); plot.setXLabelPerTic(2); return plot; } private void generatePlot(int commandIndex, DecimalFormat format, int gameIndex, String gameFile) throws Exception { String command = getCommand(commandIndex); Table table = TableUtil.select(m_table, "File", gameFile, "Move", command); File file = getPlotFile(gameIndex, commandIndex); Plot plot = generatePlotMove(getImgWidth(m_maxMove), getColor(command)); plot.setFormatY(format); plot.plot(file, table, "Move", command, null); } private Color getColor(String command) throws Table.InvalidLocation { int index = m_table.getColumnIndex(command); return PLOT_COLOR[(index - 2) % PLOT_COLOR.length]; } private String getGameLink(File fromFile, int gameNumber, boolean shortName) { GameData data = m_gameData.get(gameNumber); File gameFile = new File(data.m_file); if (! gameFile.exists()) return (shortName ? gameFile.getName() : gameFile.toString()); String path = FileUtil.getRelativeURI(fromFile, gameFile); return "<a href=\"" + path + "\">" + (shortName ? gameFile.getName() : path) + "</a>"; } private int getImgWidth(int numberMoves) { return Math.max(10, Math.min(numberMoves * 9, 1040)); } private void initGameData() throws Table.InvalidLocation, IOException { m_gameData = new ArrayList<GameData>(); String last = null; GameData data = null; m_maxMove = 0; int[] count = new int[0]; for (int row = 0; row < m_table.getNumberRows(); ++row) { String file = m_table.get("File", row); int move = Integer.parseInt(m_table.get("Move", row)); if (move >= count.length) { int[] newCount = new int[move + 1]; for (int i = 0; i < count.length; ++i) newCount[i] = count[i]; count = newCount; } ++count[move]; m_maxMove = Math.max(m_maxMove, move); if (last == null || ! file.equals(last)) { if (data != null) m_gameData.add(data); data = new GameData(); data.m_file = file; data.m_name = new File(file).getName(); } ++data.m_numberPositions; data.m_finalPosition = move; last = file; } m_movePrintInterval = 1; while (m_movePrintInterval < m_maxMove / 30) { m_movePrintInterval *= 5; if (m_movePrintInterval >= m_maxMove / 30) break; m_movePrintInterval *= 2; } m_gameData.add(data); m_tableFinal = new Table(m_table.getColumnTitles()); for (int i = 0; i < m_gameData.size(); ++i) { data = m_gameData.get(i); String file = data.m_file; String finalPosition = Integer.toString(data.m_finalPosition); int row = TableUtil.findRow(m_table, "File", file, "Move", finalPosition); TableUtil.appendRow(m_tableFinal, m_table, row); } ArrayList<String> columnTitles = new ArrayList<String>(); columnTitles.add("Move"); columnTitles.add("Count"); Table table = new Table(columnTitles); for (int i = 0; i < count.length; ++i) { if (count[i] == 0) continue; table.startRow(); table.set("Move", i); table.set("Count", count[i]); } Plot plot = generatePlotMove(getImgWidth(m_maxMove), Color.DARK_GRAY); plot.setNoPlotYZero(); plot.plot(getCountFile(), table, "Move", "Count", null); FileWriter writer = new FileWriter(getCountDataFile()); try { table.save(writer, false); } finally { writer.close(); } } private boolean isGameGlobalCommand(String command) { for (int i = 0; i < m_gameGlobalCommands.size(); ++i) if (getGameGlobalCommand(i).getName().equals(command)) return true; return false; } private void startHtml(PrintStream out, String title) { String charset = StringUtil.getDefaultEncoding(); out.print("<html>\n" + "<head>\n" + "<title>" + title + "</title>\n" + "<meta http-equiv=\"Content-Type\"" + " content=\"text/html; charset=" + charset + "\">\n" + HtmlUtil.getMeta("gogui-statistics") + "<style type=\"text/css\">\n" + "<!--\n" + "a:link { color:#0000ee }\n" + "a:visited { color:#551a8b }\n" + ".smalltable { font-size:80%; }\n" + ".smalltable td { background-color:" + COLOR_INFO + "; text-align:center; }\n" + ".smalltable th { background-color:" + COLOR_HEADER + "; vertical-align:top; }\n" + ".smalltable table { border:0; cellpadding:0; }\n" + "-->\n" + "</style>\n" + "</head>\n" + "<body bgcolor=\"white\" text=\"black\">\n"); } private void startInfo(PrintStream out, String title) { out.print("<table border=\"0\" width=\"100%\" bgcolor=\"" + COLOR_HEADER + "\">\n" + "<tr><td>\n" + "<h1>" + title + "</h1>\n" + "</td></tr>\n" + "</table>\n" + "<table width=\"100%\" bgcolor=\"" + COLOR_INFO + "\" >\n" + "<tr><td><table style=\"font-size:80%\"" + " cellpadding=\"0\">\n"); } private void writeCommandPage(int commandIndex) throws Exception { String command = getCommand(commandIndex); CommandStatistics commandStatistics = getCommandStatistics(commandIndex); File file = getCommandFile(commandIndex); PrintStream out = new PrintStream(file); startHtml(out, command); startInfo(out, command); writeInfoBasics(out); writeHtmlRow(out, "Command Index", commandIndex); endInfo(out); if (! commandStatistics.m_isBeginCommand) out.print("<p><img src=\"" + getAvgPlotFile(commandIndex).getName() + "\"></p>\n"); writeCommandStatistics(out, commandIndex); out.print("<hr>\n"); out.print("<table border=\"0\" cellspacing=\"0\"" + " cellpadding=\"5\">\n"); out.print("<tr><td>" + "<small>All</small><br>" + "<img src=\"" + getHistoFile(commandIndex).getName() + "\"></td>\n"); if (commandStatistics.m_statisticsFinal.getCount() > 0) out.print("<td>" + "<small>Final</small><br>" + "<img src=\"" + getHistoFinalFile(commandIndex).getName() + "\"></td>"); out.print("</tr>\n" + "</table>\n"); for (int i = 0; i < m_maxMove; i += m_movePrintInterval) { Histogram histogram = commandStatistics.getStatistics(i).m_histogram; if (commandStatistics.getStatistics(i).getCount() == 0) continue; Table histoTable = TableUtil.fromHistogram(histogram, command); File histoFile = getHistoFile(commandIndex, i); Color color = getColor(command); Plot plot = new Plot(180, 135, color, m_precision); commandStatistics.setHistogramProperties(plot); plot.plot(histoFile, histoTable, command, "Count", null); out.print("<table align=\"left\" border=\"0\">" + "<tr><td align=\"center\"><small>" + i + "</small><br><img src=\"" + getHistoFile(commandIndex, i).getName() + "\"></td></tr></table>\n"); } out.print("<br clear=\"left\">\n" + "<hr>\n"); writeGamePlots(out, commandIndex); finishHtml(out); out.close(); } private void writeCommandStatistics(PrintStream out, int commandIndex) throws Exception { CommandStatistics commandStatistics = getCommandStatistics(commandIndex); String command = getCommand(commandIndex); PositionStatistics statisticsAll = commandStatistics.m_statisticsAll; PositionStatistics finalStatistics = commandStatistics.m_statisticsFinal; out.print("<table class=\"smalltable\">\n"); out.print("<tr>"); out.print("<th>Move</th>"); writeStatisticsTableHeader(out); out.print("</tr>\n"); DecimalFormat format = commandStatistics.m_format; for (int i = 0; i < m_maxMove; i += m_movePrintInterval) { PositionStatistics statisticsAtMove = commandStatistics.getStatistics(i); out.print("<tr>" + "<td>" + i + "</td>"); writeStatisticsTableData(out, statisticsAtMove, format, false); out.print("</tr>\n"); } out.print("<tr style=\"font-weight:bold\">" + "<td>Final</td>"); writeStatisticsTableData(out, finalStatistics, format, false); out.print("</tr>\n"); out.print("<tr style=\"font-weight:bold\">" + "<td>All</td>"); writeStatisticsTableData(out, statisticsAll, format, ! isGameGlobalCommand(command)); out.print("</tr>\n"); out.print("</table>\n"); } private void writeCommandsTable(PrintStream out) throws Exception { out.print("<table class=\"smalltable\">\n" + "<thead><tr>" + "<th>Command</th>"); writeStatisticsTableHeader(out); out.print("</tr></thead>\n"); for (int i = 0; i < m_commands.size(); ++i) { CommandStatistics commandStatistics = getCommandStatistics(i); int count = commandStatistics.getCount(); if (count > 0 && ! commandStatistics.m_isBeginCommand) writeCommandPage(i); PositionStatistics statisticsAll = commandStatistics.m_statisticsAll; out.print("<tr><td style=\"background-color:" + COLOR_HEADER + "\">" + getCommandLink(i) + "</td>"); writeStatisticsTableData(out, statisticsAll, commandStatistics.m_format, ! isGameGlobalCommand(getCommand(i))); out.print("</tr>\n"); } out.print("</table>\n"); } private void writeGamePage(String game, String name, int gameNumber) throws Exception { File file = getGameFile(gameNumber); PrintStream out = new PrintStream(file); String title = "Game " + (gameNumber + 1) + " (" + name + ")"; startHtml(out, title); startInfo(out, title); writeInfoBasics(out); writeHtmlRow(out, "Game Index", gameNumber); writeHtmlRow(out, "File", getGameLink(file, gameNumber, false)); try { InputStream in = new FileInputStream(new File(game)); SgfReader reader = new SgfReader(in, new File(game), null, 0); GameTree tree = reader.getTree(); GameInfo info = tree.getGameInfo(tree.getRoot()); String playerBlack = info.get(StringInfoColor.NAME, BLACK); if (playerBlack == null) playerBlack = "?"; String playerWhite = info.get(StringInfoColor.NAME, WHITE); if (playerWhite == null) playerWhite = "?"; String result = info.get(StringInfo.RESULT); if (result == null) result = "?"; writeHtmlRow(out, "Black", playerBlack); writeHtmlRow(out, "White", playerWhite); writeHtmlRow(out, "Result", result); in.close(); } catch (Exception e) { StringUtil.printException(e); } endInfo(out); out.print("<table border=\"0\">\n"); for (int i = 0; i < m_commands.size(); ++i) { CommandStatistics commandStatistics = getCommandStatistics(i); if (commandStatistics.getCount() > 0 && ! commandStatistics.m_isBeginCommand) { generatePlot(i, commandStatistics.m_format, gameNumber, game); out.print("<tr><td align=\"center\">" + getCommandLink(i) + "<br><img src=\"" + getPlotFile(gameNumber, i).getName() + "\"></td></tr>\n"); } } out.print("</table>\n" + "<hr>\n"); Table table = TableUtil.select(m_table, "File", game); out.print("<table class=\"smalltable\">\n" + "<thead><tr>"); for (int i = 1; i < table.getNumberColumns(); ++i) { String command = table.getColumnTitle(i); if (! TableUtil.allEmpty(table, command)) out.print("<th>" + command + "</th>"); } out.print("</tr></thead>\n"); for (int i = 0; i < table.getNumberRows(); ++i) { out.print("<tr>"); for (int j = 1; j < table.getNumberColumns(); ++j) { String command = table.getColumnTitle(j); if (TableUtil.allEmpty(table, command)) continue; String value = table.get(command, i); if (value == null) value = ""; out.print("<td>" + value + "</td>"); } out.print("</tr>\n"); } out.print("</table>\n"); finishHtml(out); out.close(); } private void writeGamePlots(PrintStream out, int commandIndex) throws Exception { out.print("<table border=\"0\" cellpadding=\"0\"" + " cellspacing=\"0\">\n"); for (int i = 0; i < m_gameData.size(); ++i) { String plotFile = getPlotFile(i, commandIndex).getName(); File file = getGameFile(i); out.print("<tr><td align=\"left\"><small><a href=\"" + file.getName() + "\">Game " + (i + 1) + "</a> (" + getGameLink(file, i, true) + "):</small><br>\n" + "<img src=\"" + plotFile + "\"></td></tr>\n"); } out.print("</table>\n"); } private void writeGameTable(PrintStream out) throws Exception { out.print("<table class=\"smalltable\">\n" + "<thead><tr><th>Game</th><th>File</th><th>Positions</th>"); for (int i = 0; i < m_gameGlobalCommands.size(); ++i) if (! getGameGlobalCommand(i).allEmpty()) out.print("<th>" + getGameGlobalCommand(i).m_name + "</th>"); out.print("</tr></thead>\n"); for (int i = 0; i < m_gameData.size(); ++i) { GameData data = m_gameData.get(i); String file = getGameFile(i).getName(); out.print("<tr><td style=\"background-color:" + COLOR_HEADER + "\"><a href=\"" + file + "\">Game " + (i + 1) + "</a></td><td>" + data.m_name + "</td><td>" + data.m_numberPositions + "</td>"); for (int j = 0; j < m_gameGlobalCommands.size(); ++j) if (! getGameGlobalCommand(j).allEmpty()) out.print("<td>" + getGameGlobalCommand(j).getResult(i) + "</td>"); out.print("</tr>\n"); writeGamePage(data.m_file, data.m_name, i); } out.print("</table>\n"); } private void writeHtmlRow(PrintStream out, String label, String value) throws Exception { out.print("<tr><th align=\"left\">" + label + ":</th>" + "<td align=\"left\">" + value + "</td></tr>\n"); } private void writeHtmlRow(PrintStream out, String label, int value) throws Exception { writeHtmlRow(out, label, Integer.toString(value)); } private void writeInfoBasics(PrintStream out) throws Exception { writeTableProperty(out, "Name"); writeTableProperty(out, "Version"); writeTableProperty(out, "Date"); writeTableProperty(out, "Host"); writeTableProperty(out, "Program"); } private void writeInfo(PrintStream out) throws Exception { writeInfoBasics(out); writeTableProperty(out, "Size"); writeTableProperty(out, "Games"); writeHtmlRow(out, "Positions", m_table.getNumberRows()); writeTableProperty(out, "Backward"); } private void writePlot(PrintStream out, String title, String file, String info) { out.print("<table width=\"100%\" cellspacing=\"0\"" + " cellpadding=\"0\">\n" + "<tr><td><table width=\"100%\" border=\"0\"" + " cellpadding=\"0\" bgcolor=\"" + COLOR_HEADER + "\">\n" + "<tr><td"); if (! info.equals("")) out.print(" width=\"90%\""); out.print(" align=\"center\">\n" + title + "\n" + "</td>"); if (! info.equals("")) out.print("<td align=\"right\">\n" + info + "\n</td>"); out.print("</tr></table></td></tr>\n" + "<tr><td bgcolor=\"" + COLOR_INFO + "\">\n" + "<img src=\"" + file + "\"></td></tr>\n" + "</table>\n"); } private void writeStatisticsTableData(PrintStream out, PositionStatistics statistics, DecimalFormat format, boolean withMaxError) { boolean empty = (statistics.getCount() == 0); boolean greaterOne = (statistics.getCount() > 1); out.print("<td>"); if (! empty) out.print(format.format(statistics.getMean())); out.print("</td><td>"); if (greaterOne) out.print(format.format(statistics.getDeviation())); else if (! empty) out.print(""); out.print("</td><td>"); if (greaterOne) out.print(format.format(statistics.getError())); else if (! empty) out.print(""); out.print("</td><td>"); if (greaterOne && withMaxError) { int movesPerGame = m_table.getNumberRows() / m_gameData.size(); out.print(format.format(statistics.getMaxError(movesPerGame))); } else if (! empty) out.print(""); out.print("</td><td>"); if (greaterOne) out.print(format.format(statistics.getMin())); else if (! empty) out.print(""); out.print("</td><td>"); if (greaterOne) out.print(format.format(statistics.getMax())); else if (! empty) out.print(""); out.print("</td><td>"); if (greaterOne) out.print(format.format(statistics.getSum())); else if (! empty) out.print(""); out.print("</td><td>"); out.print(statistics.getCount()); out.print("</td><td>"); out.print(statistics.m_numberNoResult); out.print("</td>"); } private void writeStatisticsTableHeader(PrintStream out) { out.print("<th>Mean</th>" + "<th>Deviation</th>" + "<th>Error</th>" + "<th>MaxError</th>" + "<th>Min</th>" + "<th>Max</th>" + "<th>Sum</th>" + "<th>Count</th>" + "<th>Unknown</th>"); } private void writeTableProperty(PrintStream out, String key) throws Exception { writeHtmlRow(out, key, m_table.getProperty(key, "?")); } }