diff --git a/src/org/opensourcephysics/display/DataTable.java b/src/org/opensourcephysics/display/DataTable.java index 9e30e60c..6d9292cc 100644 --- a/src/org/opensourcephysics/display/DataTable.java +++ b/src/org/opensourcephysics/display/DataTable.java @@ -357,6 +357,15 @@ public void setFormatPattern(String columnName, String pattern) { * @param units the units string (may be null) * @param tootip the tooltip (may be null) */ + /** Returns existing renderer metadata; never interprets the column name. */ + public String getUnits(String columnName) { + if (columnName == null) return null; + int suffix=columnName.indexOf("_{ "); + String key=suffix>0?columnName.substring(0,suffix):columnName; + UnitRenderer renderer=unitRenderersByColumnName.get(key); + return renderer==null?null:renderer.units; + } + public void setUnits(String columnName, String units, String tooltip) { if (units == null) { unitRenderersByColumnName.remove(columnName); @@ -2643,7 +2652,10 @@ public StringBuffer getData(boolean asFormatted) { if (isRowNumberVisible() && selectedColumns[j] == 0) continue; String name = getColumnName(selectedColumns[j]); - name = TeXParser.removeSubscripting(name); + String units = getUnits(name); + name = units == null || units.trim().length() == 0 + ? ExportText.ascii(TeXParser.removeSubscripting(name)) + : ExportText.header(name, units); // if (name.startsWith(FunctionEditor.THETA)) { // for (int i = 0; i < selectedRows.length; i++) { // Object val = getFormattedValueAt(selectedRows[i], selectedColumns[j]); diff --git a/src/org/opensourcephysics/display/ExportText.java b/src/org/opensourcephysics/display/ExportText.java new file mode 100644 index 00000000..1978c6ac --- /dev/null +++ b/src/org/opensourcephysics/display/ExportText.java @@ -0,0 +1,30 @@ +package org.opensourcephysics.display; + +/** Plain-text notation for spreadsheet headers and expressions, never numeric cells. */ +public final class ExportText { + private ExportText() {} + public static String ascii(String text) { + if (text == null) return ""; + String s = text.replace("\\cdot", "*").replace("\\times", "*") + .replace("\\mu", "u").replace("\\theta", "theta").replace("\\sigma", "sigma") + .replace("\\alpha", "alpha").replace("\\beta", "beta").replace("\\omega", "omega") + .replace("\\Delta", "Delta").replace("\\pi", "pi") + .replace("·", "*").replace("×", "*").replace("−", "-") + .replace("µ", "u").replace("μ", "u").replace("θ", "theta") + .replace("σ", "sigma").replace("α", "alpha").replace("β", "beta") + .replace("ω", "omega").replace("Δ", "Delta").replace("π", "pi"); + String supers = "⁰¹²³⁴⁵⁶⁷⁸⁹⁻⁺", normal = "0123456789-+"; + StringBuilder out = new StringBuilder(); boolean exponent = false; + for (int i=0;i=0) { if(!exponent) out.append('^'); out.append(normal.charAt(n)); exponent=true; } + else { out.append(s.charAt(i)); exponent=false; } + } + return out.toString().replaceAll("([_^])\\{([^{}]*)\\}", "$1$2"); + } + public static String header(String name, String units) { + String label=ascii(name), unit=ascii(units).trim(); + String suffix=" ("+unit+")"; + return unit.length()==0 || label.endsWith(suffix) ? label : label+suffix; + } +} diff --git a/src/org/opensourcephysics/resources/tools/tools.properties b/src/org/opensourcephysics/resources/tools/tools.properties index 2540c9c7..fa74af79 100644 --- a/src/org/opensourcephysics/resources/tools/tools.properties +++ b/src/org/opensourcephysics/resources/tools/tools.properties @@ -905,4 +905,96 @@ LibraryResource.Type.Data.Description=Data file LibraryResource.Type.URL.Description=URL hyperlink LibraryBrowser.Dialog.NoResources.File=The file LibraryTreePanel.Dialog.Open.Message=Do you want to open -LibraryTreePanel.Dialog.Open.Title=Open Resource? \ No newline at end of file +LibraryTreePanel.Dialog.Open.Title=Open Resource? + +# Curve fit clipboard report +DatasetCurveFitter.Button.CopyFitReport=Copy Fit Report +DatasetCurveFitter.Button.CopyFitReport.Tooltip=Copy parameters and the fit statistics shown on screen +DatasetCurveFitter.Report.Title=Curve fit report +DatasetCurveFitter.Report.Data=Data +DatasetCurveFitter.Report.Model=Model +DatasetCurveFitter.Report.Equation=Equation +DatasetCurveFitter.Report.Mode=Mode +DatasetCurveFitter.Report.Auto=Automatic fit +DatasetCurveFitter.Report.Manual=Manual parameters +DatasetCurveFitter.Report.Parameter=Parameter +DatasetCurveFitter.Report.Value=Value (full precision) +DatasetCurveFitter.Report.Uncertainty=Uncertainty (full precision) +DatasetCurveFitter.Report.Rounded=Value +/- uncertainty (rounded) +DatasetCurveFitter.Report.Fixed=Fixed +DatasetCurveFitter.Report.Yes=Yes +DatasetCurveFitter.Report.No=No +DatasetCurveFitter.Report.NA=N/A +DatasetCurveFitter.Report.Points=Data points (n) +DatasetCurveFitter.Report.Free=Free parameters +DatasetCurveFitter.Report.DF=Degrees of freedom (n - rank) +DatasetCurveFitter.Report.SSE=Residual sum of squares (SSE) +DatasetCurveFitter.Report.RMS=RMS deviation (sqrt(SSE/n)) +DatasetCurveFitter.Report.R2=R-squared (1 - SSE/SST) +DatasetCurveFitter.Report.SE=Residual standard error (sqrt(SSE/(n-p))) +DatasetCurveFitter.Report.Note=Residual statistics for the current data and parameters. Parameter uncertainties are the fitter's estimates. N/A means unavailable or undefined. + +DatasetCurveFitter.Button.CopyFitParameters=Copy Fit Parameters +DatasetCurveFitter.Button.CopyFitParameters.Tooltip=Copy full-precision values, uncertainties, and rounded reporting values +DatasetCurveFitter.Statistics.Points=Points +DatasetCurveFitter.Statistics.Free=Free parameters +DatasetCurveFitter.Statistics.DF=Degrees of freedom +DatasetCurveFitter.Statistics.R2=R-squared +DatasetCurveFitter.Statistics.SSE=SSE +DatasetCurveFitter.Statistics.SE=Residual SE + +DatasetCurveFitter.Report.Summary=SUMMARY OUTPUT +DatasetCurveFitter.Report.RegressionStatistics=Regression Statistics +DatasetCurveFitter.Report.MultipleR=Multiple R +DatasetCurveFitter.Report.RSquare=R Square (centered) +DatasetCurveFitter.Report.AdjustedRSquare=Adjusted R Square (centered) +DatasetCurveFitter.Report.StandardError=Standard Error +DatasetCurveFitter.Report.Observations=Observations +DatasetCurveFitter.Report.ANOVA=ANOVA +DatasetCurveFitter.Report.Regression=Regression +DatasetCurveFitter.Report.Residual=Residual +DatasetCurveFitter.Report.Total=Total +DatasetCurveFitter.Report.Coefficients=Coefficients + +DatasetCurveFitter.Uncertainty.Label=Data uncertainty: +DatasetCurveFitter.Uncertainty.Estimated=Unweighted - estimate from residuals +DatasetCurveFitter.Uncertainty.Constant=Known uncertainty (data units) +DatasetCurveFitter.Uncertainty.Pixels=Known uncertainty (pixels) +DatasetCurveFitter.Uncertainty.Custom=Choose a value or type a custom positive uncertainty. +DatasetCurveFitter.Uncertainty.Invalid=Unavailable: enter a positive value and check calibration. +DatasetCurveFitter.Report.Fit=FIT +DatasetCurveFitter.Report.XVariable=X variable +DatasetCurveFitter.Report.YVariable=Y variable +DatasetCurveFitter.Report.Rank=Independent parameter rank +DatasetCurveFitter.Report.Identifiability=Fit parameters are not independently identifiable +DatasetCurveFitter.Report.Residuals=RESIDUALS +DatasetCurveFitter.Report.RMSResidual=RMS residual +DatasetCurveFitter.Report.Goodness=GOODNESS OF FIT +DatasetCurveFitter.Report.ChiSquare=Chi square +DatasetCurveFitter.Report.ReducedChiSquare=Reduced chi square +DatasetCurveFitter.Report.ChiProbability=Chi-square probability Q +DatasetCurveFitter.Report.ByConstruction=by construction +DatasetCurveFitter.Report.UncertaintyModel=UNCERTAINTY MODEL +DatasetCurveFitter.Report.Estimated=Estimated from residuals +DatasetCurveFitter.Report.Specified=Specified constant sigma_y +DatasetCurveFitter.Report.EstimatedSigma=Estimated sigma_y +DatasetCurveFitter.Report.SpecifiedSigma=Specified sigma_y +DatasetCurveFitter.Report.SigmaPixels=Specified position uncertainty +DatasetCurveFitter.Report.EstimatedCaution=Residual-estimated reduced chi square is 1 by construction, not an independent goodness-of-fit test. Q is unavailable. A perfect fit has zero estimated sigma and undefined chi square. +DatasetCurveFitter.Report.QCaution=Q assumes independent Gaussian measurement errors with the stated common sigma. For nonlinear fits the rank-based degrees of freedom and Q are local approximations. +DatasetCurveFitter.Report.R2Description=Centered R Square describes variance explained, not proof of goodness of fit. Excel uses an uncentered convention for some fits through zero. +DatasetCurveFitter.Report.Parameters=PARAMETERS +DatasetCurveFitter.Report.Units=Units + +DatasetCurveFitter.Report.ResidualStandardError=Residual standard error + +DatasetCurveFitter.Menuitem.CopyFullReport=Copy Full Fit Report +DatasetCurveFitter.Button.CopyOptions.Tooltip=More copy options (full fit analysis) + +DatasetCurveFitter.Uncertainty.Value=Uncertainty value: + +DatasetCurveFitter.Report.Velocity=velocity +DatasetCurveFitter.Report.VelocityAtZero=velocity at t=0 +DatasetCurveFitter.Report.Acceleration=acceleration +DatasetCurveFitter.Report.MotionResults=MOTION FROM POSITION FIT +DatasetCurveFitter.Report.Quantity=Quantity diff --git a/src/org/opensourcephysics/tools/CurveFitReport.java b/src/org/opensourcephysics/tools/CurveFitReport.java new file mode 100644 index 00000000..e7ff08f3 --- /dev/null +++ b/src/org/opensourcephysics/tools/CurveFitReport.java @@ -0,0 +1,291 @@ +package org.opensourcephysics.tools; + +import org.opensourcephysics.display.Dataset; +import org.opensourcephysics.display.OSPRuntime; + +/** Tab-separated snapshots of curve fits for spreadsheets and reports. */ +final class CurveFitReport { + private CurveFitReport() {} + static final String[] STATISTIC_KEYS = {"Points", "Free", "DF", "SSE", "RMS", "R2", "SE"}; + + static String create(KnownFunction fit, Dataset data, boolean[] fixed, + double[] uncertainties, boolean autofit, boolean includeStatistics) { + return create(fit,data,fixed,uncertainties,autofit,includeStatistics,null,null,FitUncertainty.estimated(),Double.NaN); + } + static String create(KnownFunction fit, Dataset data, boolean[] fixed, + double[] uncertainties, boolean autofit, boolean includeStatistics, + String xUnits, String yUnits, FitUncertainty uncertainty, double unitsPerPixel) { + StringBuilder out=new StringBuilder(); + if(includeStatistics) { + String xName=data==null?"x":data.getXColumnName(), yName=data==null?"y":data.getYColumnName(); + row(out,label("Fit"),data==null?"":data.getName()); + row(out,label("Model"),fit.getName(),label(autofit?"Auto":"Manual")); + row(out,label("XVariable"),org.opensourcephysics.display.ExportText.header(xName,xUnits)); + row(out,label("YVariable"),org.opensourcephysics.display.ExportText.header(yName,yUnits)); + row(out,label("Equation"),org.opensourcephysics.display.ExportText.ascii(yName+" = "+fit.getExpression(xName))); + double[] stats=statistics(fit,data,fixed,autofit); + double n=stats[0],p=stats[1],df=stats[2],sse=stats[3],r2=stats[5],sst=stats[7]; + row(out,label("Observations"),integer(n)); + row(out,label("Free"),integer(p)); + row(out,label("Rank"),stats[8]<0?label("NA"):integer(stats[8])); + row(out,label("DF"),integer(df)); + if(autofit && stats[8]>=0 && stats[8]0 && n>1 && sst>0?1-(sse/df)/(sst/(n-1)):Double.NaN)); + row(out,label("MultipleR"),number(autofit && fit instanceof KnownPolynomial && p==fit.getParameterCount() + && stats[8]==p && r2>=0?Math.sqrt(r2):Double.NaN)); + double[] chi=uncertainty.statistics(stats,autofit,unitsPerPixel); + row(out,label("ChiSquare"),number(chi[1])); + row(out,label("ReducedChiSquare"),number(chi[2]),uncertainty.mode==FitUncertainty.ESTIMATED && isFinite(chi[2])?label("ByConstruction"):""); + row(out,label("ChiProbability"),number(chi[3])); + row(out,label("R2Description")); + row(out);row(out,label("UncertaintyModel"),label(uncertainty.mode==FitUncertainty.ESTIMATED?"Estimated":"Specified")); + row(out,label(uncertainty.mode==FitUncertainty.ESTIMATED?"EstimatedSigma":"SpecifiedSigma"),number(chi[0]),org.opensourcephysics.display.ExportText.ascii(yUnits)); + if(uncertainty.mode==FitUncertainty.PIXELS)row(out,label("SigmaPixels"),number(uncertainty.value),"pixels"); + if(uncertainty.mode==FitUncertainty.ESTIMATED)row(out,label("EstimatedCaution")); + else row(out,label("QCaution")); + row(out); + row(out, label("ANOVA")); + row(out, "", "df", "SS", "MS", "F"); + // Classical regression ANOVA applies to an automatically fitted, + // full-rank polynomial with a freely fitted intercept and no constraints. + boolean regression = autofit && fit instanceof KnownPolynomial && p == fit.getParameterCount() + && stats[8] == p && df > 0 && p > 1 && sst > 0 && sse >= 0 && sse <= sst + && hasDistinctAbscissas(data, (int) p); + double regressionDF = regression ? p - 1 : Double.NaN; + double regressionSS = regression ? sst - sse : Double.NaN; + double regressionMS = regressionSS / regressionDF; + double residualMS = df > 0 ? sse / df : Double.NaN; + row(out, label("Regression"), integer(regressionDF), number(regressionSS), number(regressionMS), + number(regression && residualMS > 0 ? regressionMS / residualMS : Double.NaN)); + row(out, label("Residual"), integer(df), number(sse), number(residualMS)); + row(out, label("Total"), n > 0 ? Integer.toString((int) n - 1) : label("NA"), number(sst)); + row(out); + } + if(includeStatistics)row(out,label("Parameters")); + row(out,label("Parameter"),label("Coefficients"),label("StandardError"),label("Units"),label("Fixed")); + for(int i=0;i= 0 && stats[8] < stats[1]) row(out, label("Identifiability")); + if (uncertainty.mode != FitUncertainty.ESTIMATED) { + row(out, label("SpecifiedSigma"), number(uncertainty.statistics(stats, autofit, unitsPerPixel)[0]), + org.opensourcephysics.display.ExportText.ascii(yUnits)); + if (uncertainty.mode == FitUncertainty.PIXELS) + row(out, label("SigmaPixels"), number(uncertainty.value), "pixels"); + } + return out.toString(); + } + + /** Exact transformations of polynomial coefficients; no new fit or covariance approximation. */ + static String[][] motionResults(KnownFunction fit, boolean[] fixed, double[] uncertainties, + boolean autofit, String xUnits, String yUnits, String component) { + if (component == null || !(fit instanceof KnownPolynomial) + || fit.getParameterCount() < 2 || fit.getParameterCount() > 3) return new String[0][]; + int degree = fit.getParameterCount() - 1; + String[][] results = new String[degree][]; + for (int i = 0; i < degree; i++) { + int parameter = degree == 1 ? 0 : 1 - i; + double factor = i == 0 ? 1 : 2; + boolean isFixed = fixed != null && parameter < fixed.length && fixed[parameter]; + double sigma = autofit && !isFixed && uncertainties != null && parameter < uncertainties.length + ? factor * uncertainties[parameter] : Double.NaN; + if (sigma < 0) sigma = Double.NaN; + results[i] = new String[]{component + " " + label(i == 1 ? "Acceleration" + : degree == 1 ? "Velocity" : "VelocityAtZero"), + number(factor * fit.getParameterValue(parameter)), number(sigma), + coefficientUnits(fit, parameter, xUnits, yUnits), label(isFixed ? "Yes" : "No")}; + } + return results; + } + + static String appendMotionResults(String report, String[][] results) { + if (results.length == 0) return report; + StringBuilder out = new StringBuilder(report); + row(out); + row(out, label("MotionResults")); + row(out, label("Quantity"), label("Value"), label("StandardError"), label("Units"), label("Fixed")); + for (String[] result : results) row(out, result); + return out.toString(); + } + + static String coefficientUnits(KnownFunction fit,int parameter,String xUnits,String yUnits) { + if(!(fit instanceof KnownPolynomial))return ""; + String x=org.opensourcephysics.display.ExportText.ascii(xUnits).trim(); + String y=org.opensourcephysics.display.ExportText.ascii(yUnits).trim(); + int power=fit.getParameterCount()-1-parameter; + if(y.length()==0)return ""; + if(power==0)return y; + if(x.length()==0)return ""; + String denominator=x.matches("[A-Za-z0-9_]+")?x:"("+x+")"; + return y+"/"+denominator+(power==1?"":"^"+power); + } + + private static boolean hasDistinctAbscissas(Dataset data, int required) { + java.util.HashSet distinct = new java.util.HashSet(); + for (double x : data.getValidXPoints()) { + if (!isFinite(x)) return false; + distinct.add(x == 0 ? 0.0 : x); + if (distinct.size() >= required) return true; + } + return false; + } + + // SwingJS does not expose Java 8's Double.isFinite overload. + static boolean isFinite(double value) { + return !Double.isNaN(value) && !Double.isInfinite(value); + } + + private static String integer(double value) { + return isFinite(value) ? Integer.toString((int) value) : label("NA"); + } + + /** n, p, df, SSE, RMS, R-squared, residual standard error, SST, local rank; shared by screen and export. */ + static double[] statistics(KnownFunction fit, Dataset data, boolean[] fixed, boolean autofit) { + int freeParameters = 0; + for (int i = 0; i < fit.getParameterCount(); i++) + if (fixed == null || i >= fixed.length || !fixed[i]) freeParameters++; + double[] x = data == null ? new double[0] : data.getValidXPoints(); + double[] y = data == null ? new double[0] : data.getValidYPoints(); + int n = x.length; + double sse = 0; + double mean = 0; + double sst = 0; + for (int i = 0; i < n; i++) { + double predicted = fit.evaluate(x[i]); + double residual = y[i] - predicted; + sse += residual * residual; + double delta = y[i] - mean; + mean += delta / (i + 1); + sst += delta * (y[i] - mean); + if (!isFinite(x[i]) || !isFinite(y[i]) || !isFinite(predicted) + || (fit instanceof UserFunction && ((UserFunction) fit).evaluatedToNaN())) + sse = Double.NaN; + } + int rank = autofit && n > 0 ? parameterRank(fit, fixed, x) : -1; + int df = rank >= 0 ? n - rank : -1; + boolean valid = n > 0 && isFinite(sse); + return new double[] {n, freeParameters, autofit && n > 0 && rank >= 0 ? df : Double.NaN, + valid ? sse : Double.NaN, valid ? Math.sqrt(sse / n) : Double.NaN, + valid && isFinite(sst) && sst > 0 ? 1 - sse / sst : Double.NaN, + valid && autofit && df > 0 ? Math.sqrt(sse / df) : Double.NaN, + n > 0 && isFinite(sst) ? sst : Double.NaN, rank}; + } + + /** Numerical rank of the free-parameter Jacobian, evaluated on a clone. + * Column normalization makes the tolerance independent of parameter units. + * For nonlinear models this is a local, linearized degrees-of-freedom estimate. + */ + static int parameterRank(KnownFunction fit, boolean[] fixed, double[] x) { + KnownFunction probe = fit.clone(); + double[][] columns = new double[fit.getParameterCount()][x.length]; + int count = 0; + for (int j = 0; j < fit.getParameterCount(); j++) { + if (fixed != null && j < fixed.length && fixed[j]) continue; + double value = fit.getParameterValue(j); + double step = 1e-5 * Math.max(1, Math.abs(value)); + double norm = 0; + for (int i = 0; i < x.length; i++) { + if (!isFinite(x[i])) return -1; + double derivative; + if (fit instanceof KnownPolynomial) { + // Polynomial parameters run from highest power to constant. + // Exact derivatives avoid cancellation from large coefficients. + derivative = Math.pow(x[i], fit.getParameterCount() - 1 - j); + } else { + probe.setParameterValue(j, value + step); + double high = probe.evaluate(x[i]); + if (probe instanceof UserFunction && ((UserFunction) probe).evaluatedToNaN()) return -1; + probe.setParameterValue(j, value - step); + double low = probe.evaluate(x[i]); + if (probe instanceof UserFunction && ((UserFunction) probe).evaluatedToNaN()) return -1; + derivative = (high - low) / (2 * step); + } + if (!isFinite(derivative)) return -1; + columns[count][i] = derivative; + norm = Math.hypot(norm, derivative); + } + probe.setParameterValue(j, value); + if (norm > 0) { + for (int i = 0; i < x.length; i++) columns[count][i] /= norm; + count++; + } + } + // Pivoted, reorthogonalized Gram-Schmidt on normalized columns. + int rank = 0; + while (rank < count && rank < x.length) { + int pivot = rank; + double largest = 0; + for (int j = rank; j < count; j++) { + double norm = 0; + for (double v : columns[j]) norm = Math.hypot(norm, v); + if (norm > largest) { largest = norm; pivot = j; } + } + if (largest < 1e-7) break; + double[] swap = columns[rank]; columns[rank] = columns[pivot]; columns[pivot] = swap; + for (int i = 0; i < x.length; i++) columns[rank][i] /= largest; + for (int j = rank + 1; j < count; j++) { + for (int pass = 0; pass < 2; pass++) { + double dot = 0; + for (int i = 0; i < x.length; i++) dot += columns[rank][i] * columns[j][i]; + for (int i = 0; i < x.length; i++) columns[j][i] -= dot * columns[rank][i]; + } + } + rank++; + } + return rank; + } + + private static String label(String key) { + return ToolsRes.getString("DatasetCurveFitter.Report." + key); + } + + private static String number(double value) { + return isFinite(value) + ? Double.toString(value).replace('.', OSPRuntime.getCurrentDecimalSeparator()) : label("NA"); + } + + private static void row(StringBuilder out, String... cells) { + for (int i = 0; i < 5; i++) { + if (i > 0) out.append('\t'); + out.append(i >= cells.length || cells[i] == null ? "" : cells[i].replace('\t', ' ').replace('\n', ' ').replace('\r', ' ')); + } + out.append('\n'); + } +} diff --git a/src/org/opensourcephysics/tools/DataToolTab.java b/src/org/opensourcephysics/tools/DataToolTab.java index f45b4894..3ba9f0f5 100644 --- a/src/org/opensourcephysics/tools/DataToolTab.java +++ b/src/org/opensourcephysics/tools/DataToolTab.java @@ -183,10 +183,20 @@ public DatasetCurveFitter getCurveFitter() { protected DatasetManager dataManager = new DatasetManager(); // datasets in this tab protected JSplitPane[] splitPanes; protected DataToolPlotter plot; + private FitMetadataProvider fitMetadataProvider; + /** Live metadata only; data values and saved project state are unaffected. */ + public void setFitMetadataProvider(FitMetadataProvider provider) { + fitMetadataProvider=provider; + if(curveFitter!=null)curveFitter.refreshUncertaintyModel(); + } + FitMetadataProvider getFitMetadataProvider() { return fitMetadataProvider; } + public void setColumnUnits(String name,String units) { dataTable.setUnits(name,units,null); } + protected DataToolTable dataTable; protected DataToolStatsTable statsTable; protected DataToolPropsTable propsTable; protected JScrollPane dataScroller, statsScroller, propsScroller, tableScroller; + protected JScrollPane fitScroller; protected JToolBar toolbar; protected JCheckBoxMenuItem statsCheckbox, propsCheckbox, fourierCheckbox; protected FourierPanel fourierPanel; @@ -784,6 +794,13 @@ public void saveOwnedColumnNames(String columnOwnerName, Data data) { * @param ID the ID number of the desired column * @return the tab column name, or null if not found */ + /** Resolve a source column without confusing linked x and y columns sharing an ID. */ + public String getColumnName(int ID,int sourceColumn) { + for(Dataset column:dataManager.getDatasetsRaw()) + if(column.getID()==ID && column.getColumnID()==sourceColumn)return column.getYColumnName(); + return null; + } + public String getColumnName(int ID) { for (Dataset column : dataManager.getDatasetsRaw()) { if (column.getID() == ID) @@ -1180,7 +1197,29 @@ public void propertyChange(PropertyChangeEvent e) { splitPanes[0].setResizeWeight(0.7); splitPanes[0].setOneTouchExpandable(true); // splitPanes[1] is plot on top, fitter on bottom - splitPanes[1] = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + splitPanes[1] = new JSplitPane(JSplitPane.VERTICAL_SPLIT) { + @Override + public void doLayout() { + if (fitScroller != null && getBottomComponent() == fitScroller && getHeight() > 0) { + java.awt.Insets insets = getInsets(); + // Reserve scrollbar width consistently so entering scroll mode does + // not toggle the responsive layout back and forth. + int width = Math.max(1, getWidth() - insets.left - insets.right + - fitScroller.getVerticalScrollBar().getPreferredSize().width); + int required = curveFitter.prepareFitLayout(width); + curveFitter.setPreferredSize(new Dimension(width, required)); + int available = getHeight() - insets.top - insets.bottom - getDividerSize(); + // Protect the drawable graph, not merely its panel and axis labels. + int graphHeight = Math.max(140, plot.getFontMetrics(plot.getFont()).getHeight() * 8); + int plotHeight = graphHeight + plot.getTopGutter() + plot.getBottomGutter(); + plotHeight = Math.min(plotHeight, Math.max(0, available / 2)); + int fitHeight = Math.min(required, Math.max(0, available - plotHeight)); + setDividerLocation(insets.top + available - fitHeight); + } + super.doLayout(); + } + + }; splitPanes[1].setResizeWeight(1); splitPanes[1].setDividerSize(0); // splitPanes[2] is stats/props tables on top, data table on bottom @@ -1281,7 +1320,14 @@ public void columnMoved(TableColumnModelEvent e) { public void actionPerformed(ActionEvent e) { splitPanes[1].setEnabled(true); getCurveFitter().setFontLevel(FontSizer.getLevel()); - splitPanes[1].setBottomComponent(curveFitter); + if (fitScroller == null) { + fitScroller = new JScrollPane(curveFitter, + JScrollPane.VERTICAL_SCROLLBAR_ALWAYS, JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED); + fitScroller.setBorder(BorderFactory.createEmptyBorder()); + fitScroller.setMinimumSize(new Dimension(0, 0)); + fitScroller.getVerticalScrollBar().setUnitIncrement(16); + } + splitPanes[1].setBottomComponent(fitScroller); splitPanes[1].setDividerSize(splitPanes[0].getDividerSize()); splitPanes[1].setDividerLocation(-1); if (curveFitter.getDrawer() != null) @@ -1304,7 +1350,7 @@ public void actionPerformed(ActionEvent e) { @Override public void actionPerformed(ActionEvent e) { // remove curveFitter - splitPanes[1].remove(curveFitter); + if (fitScroller != null) splitPanes[1].remove(fitScroller); splitPanes[1].setDividerSize(splitPanes[2].getDividerSize()); splitPanes[1].setDividerLocation(1.0); plot.removeDrawables(FunctionDrawer.class); @@ -2367,7 +2413,7 @@ public void actionPerformed(ActionEvent e) { private boolean isFitterVisible() { // return splitPanes[1].getDividerLocation() <= splitPanes[1].getMaximumDividerLocation(); - return curveFitter != null && splitPanes[1].getBottomComponent() == curveFitter; + return curveFitter != null && fitScroller != null && splitPanes[1].getBottomComponent() == fitScroller; } private void findHits(boolean showInTable) { diff --git a/src/org/opensourcephysics/tools/DataToolTable.java b/src/org/opensourcephysics/tools/DataToolTable.java index ee1cff78..c907d84e 100644 --- a/src/org/opensourcephysics/tools/DataToolTable.java +++ b/src/org/opensourcephysics/tools/DataToolTable.java @@ -85,6 +85,12 @@ */ @SuppressWarnings("serial") public class DataToolTable extends DataTable { + @Override public String getUnits(String name) { + FitMetadataProvider provider=dataToolTab.getFitMetadataProvider(); + String units=provider==null?null:provider.getUnits(name); + return units==null?super.getUnits(name):units; + } + // static fields and constants protected final static int RENAME_COLUMN_EDIT = 0; protected final static int INSERT_COLUMN_EDIT = 1; diff --git a/src/org/opensourcephysics/tools/DatasetCurveFitter.java b/src/org/opensourcephysics/tools/DatasetCurveFitter.java index 5677cd12..07ab2e74 100644 --- a/src/org/opensourcephysics/tools/DatasetCurveFitter.java +++ b/src/org/opensourcephysics/tools/DatasetCurveFitter.java @@ -197,6 +197,98 @@ public class DatasetCurveFitter extends JPanel { private DataToolTab tab; KnownFunction fit; // the function to fit to the data + private FitUncertainty uncertaintyModel=FitUncertainty.estimated(); + private JComboBox uncertaintyChoice, uncertaintyValue; + private JLabel uncertaintyStatus; + private boolean updatingUncertaintyControls; + public FitUncertainty getUncertaintyModel() { return uncertaintyModel; } + public void setUncertaintyModel(int mode,double value) { + uncertaintyModel=new FitUncertainty(mode,value); + refreshUncertaintyModel(); + } + /** Choose a starting scale without interpreting a physical-unit number as pixels. */ + void selectUncertaintyMode(int mode) { + double value = uncertaintyModel.value; + double calibration = getYUnitsPerPixel(); + if (mode != uncertaintyModel.mode) { + if (mode == FitUncertainty.PIXELS) { + value = uncertaintyModel.mode == FitUncertainty.CONSTANT && FitUncertainty.positive(calibration) + ? value / calibration : 1.0; + } else if (mode == FitUncertainty.CONSTANT) { + if (uncertaintyModel.mode == FitUncertainty.PIXELS) value *= calibration; + else if (FitUncertainty.positive(calibration)) value = calibration; + else value = fit == null ? Double.NaN + : CurveFitReport.statistics(fit, dataset, fixedParams.get(fit), autofit)[6]; + if (!FitUncertainty.positive(value)) value = Double.NaN; + } + } + setUncertaintyModel(mode, value); + } + + private String variableUnits(boolean x) { + return tab==null || dataset==null ? null : tab.dataTable.getUnits(x?dataset.getXColumnName():dataset.getYColumnName()); + } + public double getYUnitsPerPixel() { + FitMetadataProvider provider=tab==null?null:tab.getFitMetadataProvider(); + return provider==null || dataset==null?Double.NaN:provider.getYUnitsPerPixel(dataset.getYColumnName()); + } + /** Recompute profile errors only: do not rerun the coefficient optimizer. */ + public void refreshUncertaintyModel() { + if(fit!=null && dataset!=null && drawer!=null) { + if(autofit) { + KnownFunction probe=fit.clone(); + KnownFunction free=getTestFunction(probe,fixedParams.get(fit)); + setUncertainties(getUncertainties(probe,free,dataset.getValidXPoints(),dataset.getValidYPoints())); + } else setUncertainties(null); + drawer.functionChanged=true; + paramTable.repaint(); + } + refreshFitStatistics(); + refreshUncertaintyControls(); + } + /** Two significant digits for display only; the model retains the original value. */ + static String formatDataUncertainty(double value) { + if (!FitUncertainty.positive(value)) return ""; + DecimalFormat format = new DecimalFormat("0.0E0", OSPRuntime.getDecimalFormatSymbols()); + String scientific = format.format(value); + int exponent = Integer.parseInt(scientific.substring(scientific.indexOf('E') + 1)); + if (exponent < -3 || exponent > 1) return scientific; + String pattern = "0"; + if (exponent < 1) { + pattern += "."; + for (int i = 0; i < 1 - exponent; i++) pattern += "0"; + } + format.applyPattern(pattern); + return format.format(value); + } + + private void refreshUncertaintyControls() { + if(uncertaintyChoice==null)return; + updatingUncertaintyControls=true; + boolean pixels=FitUncertainty.positive(getYUnitsPerPixel()); + if((pixels || uncertaintyModel.mode==FitUncertainty.PIXELS) && uncertaintyChoice.getItemCount()==2) + uncertaintyChoice.addItem(ToolsRes.getString("DatasetCurveFitter.Uncertainty.Pixels")); + if(!pixels && uncertaintyModel.mode!=FitUncertainty.PIXELS && uncertaintyChoice.getItemCount()==3) + uncertaintyChoice.removeItemAt(2); + uncertaintyChoice.setSelectedIndex(uncertaintyModel.mode); + uncertaintyValue.setEnabled(uncertaintyModel.mode!=FitUncertainty.ESTIMATED); + uncertaintyValue.removeAllItems(); + if (uncertaintyModel.mode == FitUncertainty.PIXELS) + for (String preset : new String[]{"0.5", "1.0", "1.5", "2.0", "2.5", "3.0"}) uncertaintyValue.addItem(preset); + if(uncertaintyModel.mode!=FitUncertainty.ESTIMATED)uncertaintyValue.setSelectedItem(FitUncertainty.positive(uncertaintyModel.value) + ? formatDataUncertainty(uncertaintyModel.value) : ""); + else uncertaintyValue.setSelectedItem(""); + uncertaintyValue.setToolTipText(ToolsRes.getString("DatasetCurveFitter.Uncertainty.Custom") + + (uncertaintyModel.mode != FitUncertainty.ESTIMATED && FitUncertainty.positive(uncertaintyModel.value) + ? " " + Double.toString(uncertaintyModel.value).replace('.', OSPRuntime.getCurrentDecimalSeparator()) : "")); + double sigma=uncertaintyModel.sigma(Double.NaN,getYUnitsPerPixel()); + uncertaintyStatus.setText(uncertaintyModel.mode==FitUncertainty.ESTIMATED?"": + FitUncertainty.positive(sigma)?(uncertaintyModel.mode == FitUncertainty.PIXELS ? "pixels" + : org.opensourcephysics.display.ExportText.ascii(variableUnits(false))): + ToolsRes.getString("DatasetCurveFitter.Uncertainty.Invalid")); + updatingUncertaintyControls=false; + } + double sigma_y_squared = 1; // an estimate of the SD in the y deviations from the fit ArrayList testFunctions = new ArrayList();; Color color = Color.MAGENTA; @@ -253,17 +345,21 @@ public void setAutoFit(boolean autofit) { autofitCheckBox.setSelected(autofit); if (!autofit) drawer.setUncertainties(null); + refreshFitStatistics(); } // GUI - private JButton colorButton, closeButton; + private JButton colorButton, closeButton, copyFitReportButton, copyFitOptionsButton; private JCheckBox autofitCheckBox; private JLabel fitLabel, eqnLabel, rmsLabel; private JToolBar fitBar, eqnBar, rmsBar; private JComboBox fitDropDown; private JTextField eqnField; private NumberField rmsField; + private JPanel statisticsPanel, uncertaintyPanel; + private JLabel[] statisticLabels; + private static final int[] VISIBLE_STATISTICS = {5, 3, 6}; private ParamTable paramTable; private ParamCellRenderer cellRenderer; private SpinCellEditor spinCellEditor; // uses number-crawler spinner @@ -285,6 +381,7 @@ public JSplitPane getSplitPane() { */ public DatasetCurveFitter(Dataset data, FitBuilder builder) { dataset = data; + refreshUncertaintyControls(); fitBuilder = builder; createGUI(); fitBuilder.removePropertyChangeListener(fitListener); @@ -412,6 +509,7 @@ public double fit(KnownFunction fit, boolean fromScratch) { paramTable.setEnabled(false); rmsField.setText(ToolsRes.getString("DatasetCurveFitter.RMSField.NoData")); //$NON-NLS-1$ rmsField.setForeground(Color.RED); + refreshFitStatistics(); return Double.NaN; } @@ -459,7 +557,8 @@ public double fit(KnownFunction fit, boolean fromScratch) { if (nothingToTest) { setUncertainties(null); - tab.refreshPlot(); + if (tab != null) + tab.refreshPlot(); drawer.functionChanged = true; paramTable.repaint(); } @@ -547,7 +646,7 @@ public double fit(KnownFunction fit, boolean fromScratch) { if (devSq == 0) { devSq = getDevSquared(fit, x, y); } - double rmsDev = fit.getParameterCount() > x.length && autofit? + double rmsDev = getFreeParameterCount(fit) > x.length && autofit? Double.NaN: Math.sqrt(devSq / x.length); @@ -560,6 +659,8 @@ public double fit(KnownFunction fit, boolean fromScratch) { rmsField.setValue(rmsDev); rmsField.setToolTipText(null); } + refreshFitStatistics(); + refreshParameterLayout(); refreshStatusBar(); firePropertyChange(PROPERTY_DATASETCURVEFITTER_FIT, null, null); if (tab != null && tab.areaVisible && tab.measureFit) @@ -568,6 +669,58 @@ public double fit(KnownFunction fit, boolean fromScratch) { return rmsDev; } + /** Refreshes display-only residual statistics without running the optimizer. */ + private void refreshFitStatistics() { + if (statisticLabels == null) return; + double[] values = fit == null ? null : CurveFitReport.statistics(fit, dataset, fixedParams.get(fit), autofit); + for (int i = 0; i < statisticLabels.length; i++) { + int index = VISIBLE_STATISTICS[i]; + String key = CurveFitReport.STATISTIC_KEYS[index]; + double value = values == null ? Double.NaN : values[index]; + String text = !CurveFitReport.isFinite(value) ? ToolsRes.getString("DatasetCurveFitter.Report.NA") + : index < 3 ? Integer.toString((int) value) + : formatRoot("%.5g", value).replace('.', OSPRuntime.getCurrentDecimalSeparator()); + statisticLabels[i].setText(ToolsRes.getString("DatasetCurveFitter.Statistics." + key) + ": " + text); + statisticLabels[i].setToolTipText(ToolsRes.getString("DatasetCurveFitter.Report." + key) + ": " + + (CurveFitReport.isFinite(value) ? Double.toString(value).replace('.', OSPRuntime.getCurrentDecimalSeparator()) : text)); + if(index==5)statisticLabels[i].setToolTipText(ToolsRes.getString("DatasetCurveFitter.Report.R2Description")); + } + } + + private String[][] motionResults() { + FitMetadataProvider provider = tab == null ? null : tab.getFitMetadataProvider(); + String component = provider == null || dataset == null ? null + : provider.getPositionComponent(dataset.getXColumnName(), dataset.getYColumnName()); + double[] errors = fit == null ? new double[0] : new double[fit.getParameterCount()]; + for (int i = 0; i < errors.length; i++) errors[i] = getUncertainty(i); + return CurveFitReport.motionResults(fit, fixedParams.get(fit), errors, autofit, + variableUnits(true), variableUnits(false), component); + } + + /** Copies a snapshot of the current fit without fitting or rounding its data. */ + private void copyFitResults(boolean includeStatistics) { + copyFitResults(includeStatistics, false); + } + + private void copyFitResults(boolean includeStatistics, boolean fullReport) { + if (paramTable.isEditing() && !paramTable.getCellEditor().stopCellEditing()) + return; + if (fit == null) + return; + // Calibration is live host metadata; refresh pixel-scaled profile errors + // before taking a snapshot, without changing the best-fit coefficients. + if (uncertaintyModel.mode == FitUncertainty.PIXELS) refreshUncertaintyModel(); + double[] sigma = new double[fit.getParameterCount()]; + for (int i = 0; i < sigma.length; i++) + sigma[i] = getUncertainty(i); + String report = includeStatistics && !fullReport + ? CurveFitReport.createSummary(fit, dataset, fixedParams.get(fit), sigma, + autofit, variableUnits(true), variableUnits(false), uncertaintyModel, getYUnitsPerPixel()) + : CurveFitReport.create(fit, dataset, fixedParams.get(fit), sigma, + autofit, includeStatistics, variableUnits(true), variableUnits(false), uncertaintyModel, getYUnitsPerPixel()); + OSPRuntime.copy(includeStatistics ? CurveFitReport.appendMotionResults(report, motionResults()) : report, null); + } + /** * Adds a fit function. * @@ -613,41 +766,54 @@ public double getUncertainty(int paramIndex) { } /** - * Returns two strings describing a parameter and its uncertainty. - * One for display, other with more sig figs for tooltip + * Returns a rounded parameter/uncertainty display and an unrounded tooltip. + * Formatting never changes the fitted parameter or uncertainty. * * @param value the parameter value - * @param sigma the uncertainty (may be null) - * @return the format values {decimal places, format} or null if uncert unknown or zero + * @param sigma the positive, finite uncertainty + * @param extraPlaces significant digits beyond the first uncertainty digit + * @param format retained for compatibility with existing callers + * @return {display, tooltip}, or null if the uncertainty is unknown or zero */ public String[] formatUncertainParameter(double value, double sigma, int extraPlaces, NumberFormat format) { - if (Double.isNaN(sigma) || sigma <= 0) { + return formatParameterWithUncertainty(value, sigma, extraPlaces); + } + + /** Locale-stable formatting supported by both Java and the SwingJS runtime. */ + private static String formatRoot(String pattern, double value) { + java.util.Formatter formatter = new java.util.Formatter(java.util.Locale.ROOT); + try { + return formatter.format(pattern, value).toString(); + } finally { + formatter.close(); + } + } + + static String[] formatParameterWithUncertainty(double value, double sigma, int extraPlaces) { + if (Double.isNaN(sigma) || Double.isInfinite(sigma) || sigma <= 0) { return null; } - - int exp = value == 0? 0: (int) Math.floor(Math.log10(Math.abs(value))); - int expSig = sigma == 0? 0: (int) Math.floor(Math.log10(Math.abs(sigma))); - if (expSig > exp) - exp = expSig; - int shift = exp - expSig; - double multiplier = Math.pow(10, -exp); - int places = Math.max(0, shift) + extraPlaces; - - String val = String.format("%." + places + "f", value*multiplier); - String sig = String.format("%." + places + "f", sigma*multiplier); + // Find the uncertainty exponent AFTER rounding, including carries such as + // 0.0996 -> 0.10. Both displayed numbers must end at the same place. + String roundedSigma = formatRoot("%." + extraPlaces + "e", sigma); + int expSig = Integer.parseInt(roundedSigma.substring(roundedSigma.indexOf('e') + 1)); + int exp = value == 0 ? 0 : (int) Math.floor(Math.log10(Math.abs(value))); + exp = Math.max(exp, expSig); + int places = exp - expSig + extraPlaces; + double scale = Math.pow(10, exp); + String val = formatRoot("%." + places + "f", value / scale); + String sig = formatRoot("%." + places + "f", sigma / scale); String formatted = val + " \u00B1 " + sig; + if (exp != 0) + formatted = "(" + formatted + ") E" + exp; String separator = String.valueOf(OSPRuntime.getCurrentDecimalSeparator()); formatted = formatted.replace(".", separator); - if (exp != 0) - formatted = "(" + formatted +") " + String.format("E%d", exp); - - val = format.format(value); - sig = format.format(sigma); - String tooltip = val + " \u00B1 " + sig; - + // Double.toString preserves the stored double for subsequent calculations + // if a student transcribes the tooltip rather than the rounded report. + String tooltip = (Double.toString(value) + " \u00B1 " + Double.toString(sigma)).replace(".", separator); return new String[] {formatted, tooltip}; } - + /** * Gets a fit function by name. * @@ -673,10 +839,9 @@ public Map getSelectedFitParameters() { @Override public Dimension getMinimumSize() { - Dimension dim = fitBar.getPreferredSize(); - dim.height += eqnBar.getPreferredSize().height; - dim.height += rmsBar.getPreferredSize().height + 1; - return dim; + if (statisticsPanel == null) return super.getMinimumSize(); + return new Dimension(fitBar.getPreferredSize().width, + splitPane.getPreferredSize().height + statisticsPanel.getPreferredSize().height + (uncertaintyPanel==null?0:uncertaintyPanel.getPreferredSize().height) + 4); } // _______________________ protected & private methods @@ -687,7 +852,13 @@ public Dimension getMinimumSize() { */ protected void createGUI() { setLayout(new BorderLayout()); - splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); + splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT) { + @Override + public void doLayout() { + layoutFitPane(); + super.doLayout(); + } + }; splitPane.setResizeWeight(0.7); splitPane.setDividerSize(6); // create autofit checkbox @@ -891,9 +1062,12 @@ public void actionPerformed(ActionEvent e) { rmsField = new NumberField(6) { @Override public Dimension getPreferredSize() { - return fixSize(super.getPreferredSize()); + Dimension size = fixSize(super.getPreferredSize()); + size.width = Math.max(size.width, getFontMetrics(getFont()).stringWidth("-0.000E-000") + 12); + return size; } - + @Override + public Dimension getMinimumSize() { return getPreferredSize(); } }; rmsField.setEditable(false); rmsField.setEnabled(true); @@ -914,11 +1088,18 @@ public void mousePressed(MouseEvent e) { }); JScrollPane scroller = new JScrollPane(paramTable) { + @Override + public Dimension getPreferredSize() { + return new Dimension(getMinimumSize().width, + paramTable.getPreferredSize().height + paramTable.getTableHeader().getPreferredSize().height + 6); + } @Override public Dimension getMinimumSize() { - Dimension dim = spinCellEditor.spinner.getPreferredSize(); - dim.width += cellRenderer.fieldFont.getSize() * 7; - return dim; + paramTable.sizeColumnsToContents(); + Dimension dim = paramTable.getMinimumSize(); + java.awt.Insets insets = getInsets(); + dim.width += insets.left + insets.right + getVerticalScrollBar().getPreferredSize().width; + return new Dimension(dim.width, spinCellEditor.spinner.getPreferredSize().height); } }; scroller.addMouseListener(new MouseAdapter() { @@ -929,7 +1110,7 @@ public void mousePressed(MouseEvent e) { } }); splitPane.setRightComponent(scroller); - add(getSplitPane(), BorderLayout.CENTER); + add(getSplitPane(), BorderLayout.NORTH); // create fit builder button fitBuilderButton = DataTool.createButton(ToolsRes.getString("DatasetCurveFitter.Button.Define.Text")); //$NON-NLS-1$ fitBuilderButton.addActionListener(new ActionListener() { @@ -1010,7 +1191,66 @@ public void layoutContainer(Container target) { rmsBar.addSeparator(); rmsBar.add(rmsLabel); rmsBar.add(rmsField); + rmsBar.addSeparator(); + copyFitReportButton = new JButton(); + copyFitReportButton.addActionListener(e -> copyFitResults(true)); + JPanel copyButtons = new JPanel(new BorderLayout()); + copyButtons.add(copyFitReportButton, BorderLayout.CENTER); + copyFitOptionsButton = new JButton("..."); + copyFitOptionsButton.setMargin(new java.awt.Insets(2, 4, 2, 4)); + copyFitOptionsButton.addActionListener(e -> { + JPopupMenu menu = new JPopupMenu(); + JMenuItem full = new JMenuItem(ToolsRes.getString("DatasetCurveFitter.Menuitem.CopyFullReport")); + full.addActionListener(ev -> copyFitResults(true, true)); + menu.add(full); + FontSizer.setFonts(menu); + menu.show(copyFitOptionsButton, 0, copyFitOptionsButton.getHeight()); + }); + copyButtons.add(copyFitOptionsButton, BorderLayout.EAST); + rmsBar.add(copyButtons); rmsPanel.add(rmsBar, BorderLayout.NORTH); + statisticsPanel = new JPanel(new java.awt.GridLayout(1, 3, 10, 0)); + statisticsPanel.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); + statisticLabels = new JLabel[VISIBLE_STATISTICS.length]; + for (int i = 0; i < statisticLabels.length; i++) { + statisticLabels[i] = new JLabel(); + statisticsPanel.add(statisticLabels[i]); + } + JPanel statisticsContainer = new JPanel(new BorderLayout()); + statisticsContainer.add(statisticsPanel, BorderLayout.NORTH); + uncertaintyPanel=new JPanel(new java.awt.GridLayout(0,1,0,2)); + JPanel uncertaintyModeRow = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT,4,2)); + JPanel uncertaintyValueRow = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.LEFT,4,2)); + uncertaintyModeRow.add(new JLabel(ToolsRes.getString("DatasetCurveFitter.Uncertainty.Label"))); + uncertaintyChoice=new JComboBox(new String[]{ToolsRes.getString("DatasetCurveFitter.Uncertainty.Estimated"),ToolsRes.getString("DatasetCurveFitter.Uncertainty.Constant")}); + uncertaintyValue=new JComboBox(); + uncertaintyValue.setPrototypeDisplayValue("0.0000000000"); + uncertaintyValue.setEditable(true); + if (uncertaintyValue.getEditor().getEditorComponent() instanceof JTextField) + ((JTextField) uncertaintyValue.getEditor().getEditorComponent()).setColumns(12); + uncertaintyValue.setToolTipText(ToolsRes.getString("DatasetCurveFitter.Uncertainty.Custom")); + uncertaintyStatus=new JLabel(); + java.awt.event.ActionListener changed=e->{ + if(updatingUncertaintyControls)return; + String entered = uncertaintyValue.getEditor().getItem().toString().trim(); + // Enter/focus events on unchanged display text must not round the stored scale. + if (entered.equals(formatDataUncertainty(uncertaintyModel.value))) return; + double value=Double.NaN; + try { value=Double.parseDouble(entered.replace(OSPRuntime.getCurrentDecimalSeparator(),'.')); } + catch(Exception ex) { /* Keep invalid supplied input unavailable, never estimate it. */ } + setUncertaintyModel(uncertaintyChoice.getSelectedIndex(),value); + }; + uncertaintyChoice.addActionListener(e -> { + if (!updatingUncertaintyControls) selectUncertaintyMode(uncertaintyChoice.getSelectedIndex()); + }); + uncertaintyValue.addActionListener(changed); + uncertaintyModeRow.add(uncertaintyChoice); + uncertaintyValueRow.add(new JLabel(ToolsRes.getString("DatasetCurveFitter.Uncertainty.Value"))); + uncertaintyValueRow.add(uncertaintyValue);uncertaintyValueRow.add(uncertaintyStatus); + uncertaintyPanel.add(uncertaintyModeRow);uncertaintyPanel.add(uncertaintyValueRow); + statisticsContainer.add(uncertaintyPanel,BorderLayout.SOUTH); + refreshUncertaintyControls(); + add(statisticsContainer, BorderLayout.CENTER); refreshGUI(); // refreshFitDropDown(); } @@ -1083,6 +1323,10 @@ protected void processPropertyChange(PropertyChangeEvent e) { * Refreshes the GUI. */ protected void refreshGUI() { + copyFitOptionsButton.setToolTipText(ToolsRes.getString("DatasetCurveFitter.Button.CopyOptions.Tooltip")); + if (!OSPRuntime.isJS) copyFitOptionsButton.getAccessibleContext().setAccessibleName(ToolsRes.getString("DatasetCurveFitter.Button.CopyOptions.Tooltip")); + copyFitReportButton.setText(ToolsRes.getString("DatasetCurveFitter.Button.CopyFitReport")); + copyFitReportButton.setToolTipText(ToolsRes.getString("DatasetCurveFitter.Button.CopyFitReport.Tooltip")); autofitCheckBox.setText(ToolsRes.getString("Checkbox.Autofit.Label")); //$NON-NLS-1$ rmsLabel.setText(ToolsRes.getString("DatasetCurveFitter.Label.RMSDeviation")); //$NON-NLS-1$ fitBuilderButton.setText(ToolsRes.getString("DatasetCurveFitter.Button.Define.Text")); //$NON-NLS-1$ @@ -1091,12 +1335,14 @@ protected void refreshGUI() { eqnLabel.setText(ToolsRes.getString("DatasetCurveFitter.Label.Equation")); //$NON-NLS-1$ updateColorButton(); refreshFitDropDown(); + refreshFitStatistics(); } /** * Refreshes the decimal separators. */ protected void refreshDecimalSeparators() { + refreshFitStatistics(); repaint(); spinCellEditor.field.setValue(spinCellEditor.field.getValue()); } @@ -1231,18 +1477,49 @@ protected void selectFit(String name) { if (fitBuilder.isVisible()) { fitBuilder.setSelectedPanel(fit.getName()); } - paramTable.getColumnModel().getColumn(1).setMaxWidth(getMinCheckboxColumnWidth() + 10); + refreshParameterLayout(); revalidate(); } setActiveAndFit(true); } - private int getMinCheckboxColumnWidth() { - String s = ToolsRes.getString("DatasetCurveFitter.Table.Heading.FixedParam"); - Font font = paramTable.getTableHeader().getFont(); - FontMetrics fm = paramTable.getTableHeader().getFontMetrics(font); - return fm.stringWidth(s); + /** Establishes orientation and height before the parent allocates plot space. */ + int prepareFitLayout(int availableWidth) { + if (statisticsPanel == null || paramTable == null) return 0; + int width = Math.max(1, availableWidth - 4); + // Reserve room before uncertainties become available. Selection changes must + // not change the orientation or the height allocated to the plot. + int parameterWidth = paramTable.getFontMetrics(paramTable.getFont()) + .stringWidth("Parameter Fixed (-0.00000 ± 0.00000) E-000") + 64; + int controlsWidth = Math.max(fitBar.getPreferredSize().width, rmsBar.getPreferredSize().width); + boolean stacked = width < controlsWidth + parameterWidth + splitPane.getDividerSize(); + int orientation = stacked ? JSplitPane.VERTICAL_SPLIT : JSplitPane.HORIZONTAL_SPLIT; + if (splitPane.getOrientation() != orientation) { + splitPane.setOrientation(orientation); + splitPane.setResizeWeight(stacked ? 0 : 1); + } + int controlsHeight = splitPane.getLeftComponent().getPreferredSize().height; + int parameterHeight = splitPane.getRightComponent().getPreferredSize().height; + int infoHeight = (stacked ? controlsHeight + parameterHeight + splitPane.getDividerSize() + : Math.max(controlsHeight, parameterHeight)) + 4; + splitPane.setPreferredSize(new Dimension(availableWidth, infoHeight)); + int divider = stacked ? controlsHeight + : Math.max(controlsWidth, Math.min(splitPane.getDividerLocation(), width - parameterWidth - splitPane.getDividerSize())); + if (splitPane.getDividerLocation() != divider) splitPane.setDividerLocation(divider); + return infoHeight + statisticsPanel.getPreferredSize().height + (uncertaintyPanel==null?0:uncertaintyPanel.getPreferredSize().height) + 4; + } + + private void layoutFitPane() { + if (splitPane.getWidth() > 0) prepareFitLayout(splitPane.getWidth()); + } + + private void refreshParameterLayout() { + if (paramTable == null || cellRenderer == null || spinCellEditor == null) return; + paramTable.sizeColumnsToContents(); + paramTable.revalidate(); + if (splitPane.getOrientation() == JSplitPane.HORIZONTAL_SPLIT && splitPane.getWidth() > 0 && splitPane.getDividerLocation() > splitPane.getMaximumDividerLocation()) + splitPane.setDividerLocation(splitPane.getMaximumDividerLocation()); } protected UserFunction createClone(KnownFunction f, String name) { @@ -1319,13 +1596,16 @@ private double getDevSquared(Function f, double[] x, double[] y) { * @param x the x data * @param y the y data * - * @return calibrated chi squared = x.length - paramCount + * @return chi squared with the selected residual-estimated or supplied scale */ private double calibrateChiSquared(KnownFunction f, double[] x, double[] y) { - int paramCount = f.getParameterCount(); - // set sigma so that chi squared is x.length - paramCount - sigma_y_squared = getDevSquared(f, x, y) / (x.length - paramCount); - return x.length - paramCount; + // Residual estimation uses the independent rank; supplied sigma is not rescaled. + int rank=CurveFitReport.parameterRank(f,null,x); + int df=rank<0?-1:x.length-rank; + double residual=getDevSquared(f,x,y); + double sigma=uncertaintyModel.sigma(df>0?Math.sqrt(residual/df):Double.NaN,getYUnitsPerPixel()); + sigma_y_squared=sigma*sigma; + return uncertaintyModel.mode==FitUncertainty.ESTIMATED?df:residual/sigma_y_squared; } /** @@ -1350,12 +1630,20 @@ private double getChiSquared(Function f, double[] x, double[] y) { * @return double[][] {{param uncertainties}, {fit params 1}, {fit params 2}, ...} */ private double[][] getUncertainties(KnownFunction original, KnownFunction fitted, double[] x, double[] y) { - // calibrate (set sigma_y_squared) and get min chi squared (= x.length-f.paramCount) + // Set the selected uncertainty scale and obtain the minimum chi squared. int fitCount = fitted.getParameterCount(); - if (fitCount == 0 || x.length - fitCount <= 0) + if (fitCount == 0 || x.length - CurveFitReport.parameterRank(fitted,null,x) <= 0) return null; double minChiSquared = calibrateChiSquared(fitted, x, y); + // A perfect fit has zero residual variance. Test identifiability using + // unscaled residuals so zero variance does not cause division by zero. + boolean perfectFit = uncertaintyModel.mode==FitUncertainty.ESTIMATED && sigma_y_squared == 0; + if (!perfectFit && !FitUncertainty.positive(sigma_y_squared)) return null; + if (perfectFit) { + sigma_y_squared = 1; + minChiSquared = 0; + } int paramCount = original.getParameterCount(); double[] params = new double[paramCount]; @@ -1379,7 +1667,8 @@ private double[][] getUncertainties(KnownFunction original, KnownFunction fitted ArrayList results = new ArrayList(); // for each parameter in fitted function, measure curvature of chi squared to get sigma - double[] sigmas = new double[paramCount]; + double[] sigmas = new double[paramCount]; + java.util.Arrays.fill(sigmas, Double.NaN); for (int i = 0; i < paramCount; i++) { if (fittedParamIndex[i] < 0) { @@ -1415,9 +1704,10 @@ else if (twiceDeltaChiSq > 2) twiceDeltaChiSq = chiSq - 2 * minChiSquared; tries ++; } - if (twiceDeltaChiSq > 0) { // success: positive curvature so can determine sigma + if (CurveFitReport.isFinite(twiceDeltaChiSq) && twiceDeltaChiSq >= 0.001) { + // Only report an uncertainty when the profile has measurable curvature. // use eqn 8.13 in Data Reduction and Error Analysis For the Physical Sciences - sigmas[i] = delta * Math.sqrt(2 / twiceDeltaChiSq); + sigmas[i] = perfectFit ? 0 : delta * Math.sqrt(2 / twiceDeltaChiSq); // offset fixed parameter by +/- sigma and save test parameters for drawer for (int j = 0; j < 2; j++) { @@ -1704,6 +1994,16 @@ private UserFunction getTestFunction(KnownFunction f, String paramName, double p return testFunction; } + /** Counts the parameters the optimizer can change. */ + private int getFreeParameterCount(KnownFunction function) { + boolean[] fixed = fixedParams.get(function); + int count = 0; + for (int i = 0; i < function.getParameterCount(); i++) { + if (fixed == null || i >= fixed.length || !fixed[i]) count++; + } + return count; + } + /** * Gets a test function that mimics an input function but fixes some parameters. * @param f a KnownFunction @@ -1902,16 +2202,39 @@ public void mousePressed(MouseEvent e) { header.addMouseListener(listener); } + /** Reserve space for the complete rendered values, including uncertainties. */ + void sizeColumnsToContents() { + if (cellRenderer == null || spinCellEditor == null || getColumnCount() != 3) return; + for (int col = 0; col < 3; col++) { + int width = getTableHeader().getFontMetrics(getTableHeader().getFont()) + .stringWidth(getColumnName(col)) + 16; + if (col == 2) width = Math.max(width, getFontMetrics(getFont()) + .stringWidth("(-0.00000 ± 0.00000) E-000") + 16); + // A detached SwingJS checkbox renderer cannot measure its DOM node. + // The fixed column already has room from its header and padding. + for (int row = 0; col != 1 && row < getRowCount(); row++) { + Component renderer = prepareRenderer(getCellRenderer(row, col), row, col); + width = Math.max(width, renderer.getPreferredSize().width + getIntercellSpacing().width + 12); + } + javax.swing.table.TableColumn column = getColumnModel().getColumn(col); + column.setMaxWidth(col < 2 ? width : Integer.MAX_VALUE); + column.setMinWidth(width); + column.setPreferredWidth(width); + } + } + public void showPopup(MouseEvent e) { JPopupMenu popup = new JPopupMenu(); JMenuItem item = new JMenuItem(ToolsRes.getString("DatasetCurveFitter.Menuitem.CopyParameters")); //$NON-NLS-1$ - item.addActionListener((ev) -> { - selectAll(); - ActionEvent event = new ActionEvent(paramTable, ActionEvent.ACTION_PERFORMED, null); - getActionMap().get("copy").actionPerformed(event); - }); + item.addActionListener(ev -> copyFitResults(false)); + popup.add(item); + item = new JMenuItem(ToolsRes.getString("DatasetCurveFitter.Button.CopyFitReport")); + item.addActionListener(ev -> copyFitResults(true)); popup.add(item); - popup.addSeparator(); + item = new JMenuItem(ToolsRes.getString("DatasetCurveFitter.Menuitem.CopyFullReport")); + item.addActionListener(ev -> copyFitResults(true, true)); + popup.add(item); + popup.addSeparator(); JCheckBoxMenuItem scientificNotationItem = new JCheckBoxMenuItem("Scientific notation"); //$NON-NLS-1$ scientificNotationItem.setSelected(!isFixedDecimalFormat); spinCellEditor.field.applyDefaultPattern(!isFixedDecimalFormat); //$NON-NLS-1$ @@ -1933,6 +2256,18 @@ public TableCellRenderer getCellRenderer(int row, int column) { return cellRenderer; } + @Override + public boolean editCellAt(int row, int column, EventObject event) { + // Reject context-menu gestures before JTable initializes the editor: + // loading the parameter spinner can otherwise disable Autofit. + if (event instanceof MouseEvent) { + MouseEvent mouse = (MouseEvent) event; + if (OSPRuntime.isPopupTrigger(mouse) || !SwingUtilities.isLeftMouseButton(mouse)) + return false; + } + return super.editCellAt(row, column, event); + } + @Override public TableCellEditor getCellEditor(int row, int column) { if (column == 1) { @@ -1965,6 +2300,7 @@ public void setFont(Font font) { } getTableHeader().setFont(font); setRowHeight(font.getSize() + 4); + refreshParameterLayout(); TableModel model = getModel(); if (model instanceof DefaultTableModel) { DefaultTableModel tm = (DefaultTableModel) model; @@ -2008,7 +2344,7 @@ else if (col == 1) { // if insufficient points to do fit return NaN if (dataset == null || - (autofit && fit.getParameterCount() > dataset.getValidXPoints().length)) + (autofit && getFreeParameterCount(fit) > dataset.getValidXPoints().length)) return Double.NaN; return Double.valueOf(fit.getParameterValue(row)); @@ -2018,8 +2354,12 @@ else if (col == 1) { public void setValueAt(Object value, int row, int col) { if (col == 1) { boolean[] fixed = fixedParams.get(fit); - if (fixed != null && fixed.length > row) { - fixed[row] = (Boolean)value; + if (fixed != null && fixed.length > row && fixed[row] != (Boolean) value) { + fixed[row] = (Boolean) value; + // Constraint changes invalidate the previous covariance estimates. + setUncertainties(null); + fit(fit); + fireTableRowsUpdated(0, getRowCount() - 1); } } } @@ -2093,7 +2433,7 @@ public Component getTableCellRendererComponent(JTable table, Object value, boole DecimalFormat format = spinCellEditor.field.format; format.setDecimalFormatSymbols(OSPRuntime.getDecimalFormatSymbols()); double uncertainty = getUncertainty(row); - String[] uncert = formatUncertainParameter((double)value, uncertainty, 0, format); + String[] uncert = formatUncertainParameter((double)value, uncertainty, 1, format); if (Double.isNaN((Double)value)) { tooltip = ToolsRes.getString("DatasetCurveFitter.InsufficientData.ToolTip"); //$NON-NLS-1$//$NON-NLS-2$ } diff --git a/src/org/opensourcephysics/tools/FitMetadataProvider.java b/src/org/opensourcephysics/tools/FitMetadataProvider.java new file mode 100644 index 00000000..770ae354 --- /dev/null +++ b/src/org/opensourcephysics/tools/FitMetadataProvider.java @@ -0,0 +1,17 @@ +package org.opensourcephysics.tools; + +/** Optional live host metadata. No parsing of names and no serialized state. + * A future per-observation uncertainty provider can be added separately. + */ +public interface FitMetadataProvider { + /** Null means no host metadata: retain DataTable's existing units. */ + String getUnits(String columnName); + /** Position component (for example x or y) when the columns are position vs time. + * Null disables physical derivative labels. Hosts identify columns from metadata. + */ + default String getPositionComponent(String independentColumn, String dependentColumn) { return null; } + /** Physical y units per image pixel, only for a directly calibrated position. + * Return NaN for derived data, missing calibration, or varying conversion. + */ + double getYUnitsPerPixel(String columnName); +} diff --git a/src/org/opensourcephysics/tools/FitUncertainty.java b/src/org/opensourcephysics/tools/FitUncertainty.java new file mode 100644 index 00000000..e7ad507d --- /dev/null +++ b/src/org/opensourcephysics/tools/FitUncertainty.java @@ -0,0 +1,59 @@ +package org.opensourcephysics.tools; + +/** Session-only common data uncertainty. Invalid supplied input never becomes estimated input. */ +public final class FitUncertainty { + public static final int ESTIMATED=0, CONSTANT=1, PIXELS=2; + public final int mode; + public final double value; + public FitUncertainty(int mode, double value) { + if(mode<0 || mode>2)throw new IllegalArgumentException("Unknown uncertainty model"); + this.mode=mode;this.value=value; + } + public static FitUncertainty estimated() { return new FitUncertainty(ESTIMATED,Double.NaN); } + public double sigma(double residualSigma, double unitsPerPixel) { + if(mode==ESTIMATED)return residualSigma; + if(!positive(value))return Double.NaN; + return mode==CONSTANT ? value : positive(unitsPerPixel) && positive(value*unitsPerPixel) ? value*unitsPerPixel : Double.NaN; + } + static boolean positive(double x) { return CurveFitReport.isFinite(x) && x>0; } + /** sigma, chi-square, reduced chi-square, chi-square survival probability. */ + double[] statistics(double[] fitStats, boolean autofit, double unitsPerPixel) { + double df=fitStats[2], sse=fitStats[3], sigma=sigma(fitStats[6],unitsPerPixel); + double chi=Double.NaN, reduced=Double.NaN, q=Double.NaN; + if(autofit && df>0 && CurveFitReport.isFinite(sse) && sse>=0 && positive(sigma)) { + chi=mode==ESTIMATED?df:sse/sigma/sigma; + reduced=chi/df; + if(mode!=ESTIMATED)q=survival(chi,df); + } + return new double[]{sigma,chi,reduced,q}; + } + /** Q(df/2, chi-square/2), the regularized upper incomplete gamma. + * NIST DLMF 8.2.4; lower series for x=0.001 is accepted and the error is + `delta*sqrt(2/D)`. The upper target of 2 is not a final rejection condition + after the attempt limit. These thresholds are numerical heuristics, not + confidence levels or guaranteed convergence criteria. +8. For an accepted error, perform two more constrained refits at a±sigma_j. + Their parameter arrays are passed with the errors to the function drawer. + These are not a stored covariance matrix or a simultaneous confidence band. + +Each tested parameter can therefore request up to 20 shifted refits plus two +additional drawer refits. For a model with only one free coefficient, holding that coefficient at a +trial value leaves no other coefficient to refit. Runtime otherwise depends on +model evaluation, data size, and convergence of the numerical refits. + +Changing only the uncertainty choice calls `refreshUncertaintyModel`, which +clones the displayed function before recalculating its profile errors. The +original best-fit coefficients are preserved. The implementation reuses cached temporary functions and the normal fitting +code, which also refreshes parts of the display. It is not a separate numerical +routine that only returns an array of errors. Tests cover the user-visible coefficient, +Autofit, fixed-parameter, and selection behavior. + +## 6. Rank, fixed parameters, and perfect fits + +[CurveFitReport.parameterRank](../src/org/opensourcephysics/tools/CurveFitReport.java) +uses normalized free-parameter derivative columns with pivoted, reorthogonalized +Gram-Schmidt and tolerance 1e-7. Polynomial derivatives are analytic; other +functions use central differences with step `1e-5*max(1,abs(parameter))`. +This is separate from the adaptive profile step above. For nonlinear models, +the rank is local and does not establish global identifiability. + +A concrete example is `y=(A+B)*x`: the data can determine A+B, but cannot +separately determine A and B. Increasing either coefficient by the same amount +has the same effect on every predicted value, so their derivative columns are +identical. Four observations +have p=2, r=1, and df=3. Refitting B can compensate for a perturbation in A: +individual A and B errors should be unavailable. The rank determines df and whether the report warns about dependent parameters. +Each coefficient error still depends on its own curvature check. The software +does not rewrite this function as a one-parameter fit to A+B. + +A fixed coefficient reports N/A, not an estimated error of zero: its value is +an input constraint, and uncertainty in that input has not been propagated. +In manual mode, the software likewise does not report errors left over from +an earlier automatic fit. The current +implementation withholds parameter-error estimates when df<=0 even if a +supplied noise model could support inference in a different implementation. + +For exactly zero residual variance in estimated mode, the code temporarily +uses unscaled SSE to check profile curvature. An identifiable direction can +then receive zero estimated error; an unmeasurable direction remains NaN. +This is a consequence of the zero-scatter model, not proof of exact physical +knowledge. Other guards, including constant-response handling, can also make +errors unavailable. Exported chi-square remains N/A for the zero estimated +scale. A supplied positive sigma can give nonzero parameter errors even for +an exact fit; its chi-square is zero and Q=1 when df>0. + +## 7. Interpretation and limits + +The basic uncertainty model treats x as known and residual measurement errors +as independent with a common y variance. Gaussian errors and an adequate model +are needed for the usual chi-square probability interpretation. The report's +Q is the chi-square survival probability for the fitted residuals, not a +parameter p-value or the probability that the model is true. In estimated mode +Q is deliberately unavailable because the scale was learned from those residuals. + +For small samples, estimated standard errors are not automatically normal-based +confidence intervals; a regular linear model with unknown variance ordinarily +uses Student-t factors for coefficient intervals. A symmetric local error can also be misleading when the function depends +nonlinearly on its coefficients, a coefficient is near a physical boundary, +the data barely determine it, or several different coefficient sets fit well. Neither symmetric/asymmetric confidence intervals nor parameter +p-values are added by this PR. + +The numerical calculation has several limitations. Subtracting nearly equal +chi-square values can lose precision. Changing coefficient units affects the +step rule because it adds the number 1 to the coefficient magnitude. A refit +can stop short of its minimum or settle in a different local minimum. A profile +can depart substantially from a parabola. Passing the D threshold does not +establish that these problems were avoided. Very small/large uncertainty scales can +also underflow/overflow when squared. Existing tests exercise selected cases, +not all possible UserFunctions or parameterizations. + +Pixel conversion treats the calibration transform as given. It does not +propagate uncertain rulers, frame timing, lens distortion, common calibration +errors, or temporal correlations. The default residual estimate can absorb +marking noise and model inadequacy together. A large fitted error can make a +comparison with an expected value look reassuring while the measurement is +imprecise. Inspecting residuals and measurement choices remains necessary. + +Copied motion results use only exact single-coefficient transformations: +line-fit velocity A, quadratic velocity at t=0 B, and acceleration 2A with error +2*sigma_A. Velocity at another time, `2*A*t+B`, would require the A/B covariance +or a suitable reparameterized profile fit; it is not inferred by treating the +coefficient errors as independent. + +## 8. Verification and maintenance + +- [CurveFitPhysicsTest](org/opensourcephysics/tools/CurveFitPhysicsTest.java) + checks unchanged coefficients across uncertainty modes, supplied-scale error + scaling, UserFunction profile behavior, rank/df, perfect-fit cases, pixels, + and derived motion transformations. +- [CurveFitReportTest](org/opensourcephysics/tools/CurveFitReportTest.java) + includes the analytic four-observation line and report conventions. +- [CurveFitConstraintTest](org/opensourcephysics/tools/CurveFitConstraintTest.java) + checks fixed/manual behavior and redundant parameters. +- [CurveFitPrecisionTest](org/opensourcephysics/tools/CurveFitPrecisionTest.java) + separates full-precision values from display rounding. + +See [test instructions](README.md) and +[units, uncertainty modes, and integration](fit-report-physics.md). The same +computational fixtures run on the desktop and through SwingJS. The derivation +and worked numbers above are independently checkable algebra; they are not +claims that every theoretical profile is resolved exactly by the finite-step +implementation. This documentation makes no change to that implementation. diff --git a/test/fit-report-physics.md b/test/fit-report-physics.md new file mode 100644 index 00000000..af48af6d --- /dev/null +++ b/test/fit-report-physics.md @@ -0,0 +1,118 @@ +# Fit report units and uncertainty models + +## Documentation index + +| Document | Contents | +|---|---| +| [Using fit results](fit-uncertainty-guide.md) | Short lab guide to fitted values, uncertainties, and reporting. | +| [Understanding the statistics](fit-uncertainty-details.md) | Statistical reasoning and a worked example. | +| [Bevington implementation](bevington-fit-uncertainties.md) | Profile-curvature method, code, and numerical limitations. | +| [Report definitions and integration](fit-report-physics.md) **(this page)** | Units, uncertainty models, and Tracker integration. | +| [Tests and documentation](README.md) | Regression test instructions and coverage. | + +The coefficient optimizer is unchanged. A positive constant weight multiplies +the least-squares objective by a constant and therefore has the same minimizer; +changing the uncertainty controls recomputes profile errors on a cloned function +without changing the displayed coefficients. The default remains equal-weight +least squares. Manual mode does not acquire automatic-fit inference. + +## Definitions + +For selected valid observations, residuals are y_i - f(x_i), SSE is their squared +sum, and RMS residual is sqrt(SSE/n). With r the effective independent free +parameter rank, df = n-r and residual standard error is sqrt(SSE/df). + +The default uncertainty model uses that residual standard error as sigma_y. +For positive sigma_y and df, chi-square = df and reduced chi-square = 1 by +construction. Q is unavailable because sigma_y came from these same residuals. +For an exact perfect fit with estimated sigma_y = 0, chi-square and reduced +chi-square are undefined and shown as N/A rather than evaluating 0/0. + +With a supplied finite positive sigma_y, chi-square = SSE/sigma_y^2 and reduced +chi-square = chi-square/df. Q is the chi-square survival probability, the +regularized upper incomplete gamma Q(df/2, chi-square/2). The implementation +uses a convergent lower series or upper continued fraction with a Lanczos +log-gamma evaluation. Tests include published chi-square quantiles and analytic +cases. See [NIST DLMF 8.2](https://dlmf.nist.gov/8.2) and the +[NIST chi-square distribution](https://www.itl.nist.gov/div898/handbook/eda/section3/eda3666.htm). +Q assumes independent Gaussian measurement errors and a suitable model; for +nonlinear fits the rank-based interpretation is local. Invalid supplied input +never silently becomes an estimated uncertainty. Supplied sigma_y is never +rescaled to force reduced chi-square to one. + +Parameter errors retain the Bevington Eq. 8.13 profile/refit curvature method. +Only its uncertainty scale changes. This is not a replacement by Jacobian +covariance. Unidentifiable or fixed parameters retain unavailable errors. See the +[short explanation with examples](fit-uncertainty-guide.md) and the +[detailed implementation note](bevington-fit-uncertainties.md). + +Centered R Square = 1-SSE/SST, where SST uses deviations from the observed mean. +Adjusted R Square = 1-(SSE/df)/(SST/(n-1)), where defined. R Square is descriptive +variance explained; it is not proof of model adequacy. The report retains the +warning that Excel uses another convention for fits through zero. Valid classical +polynomial ANOVA remains supplemental to the physics sections. + +## Units and clipboard + +DataTable.getUnits exposes existing renderer unit metadata. DataTool may also +receive a live FitMetadataProvider from its host. Units appear only in headers; +raw/formatted numeric serialization and delimiter selection are unchanged. +ExportText converts supported mathematical display notation to spreadsheet text, +such as m/s^2 and A*t^2. Header construction avoids adding an already present +identical unit suffix. Paste retains literal headers; it does not infer metadata +by parsing units from column names. + +KnownPolynomial exposes parameters in descending degree order, so parameter i +has degree parameterCount-1-i. Its unit is y-unit / x-unit^degree, with the +constant's unit equal to y-unit. Missing required units leave the field blank. +UserFunction parameter units remain blank: no symbolic dimensional inference. + +## Tracker integration + +OSP has no Tracker dependency. FitMetadataProvider supplies units and an optional +physical-y-units-per-pixel conversion. A small companion Tracker adapter resolves +source dataset IDs plus source column IDs, uses existing unit/calibration methods, +and limits pixel mode to directly calibrated PointMass x/y data. Derived +velocities, accelerations, and data functions do not receive pixel conversion. + +For isotropic independent pixel-coordinate error, physical sigma_y is pixel +sigma times hypot(a,b), where (a,b) is the relevant row of the image-to-world +linear transform. With isotropic calibration this is pixel sigma / pixels-per-unit. +A common conversion requires fixed scale, and either fixed angle or isotropic +scale. Moving origins do not affect uncertainty. Unsupported varying calibration +returns unavailable; it is not guessed. Presets are 0.5, 1, 1.5, 2, 2.5 and 3 +pixels; the editable field accepts a custom positive value. + +Metadata and uncertainty controls are session-only, with no project format change. +Dataset drawing error bars exist, but Data Tool has no selection-aligned fitting +uncertainty-column association. This change adds no per-point data-model redesign; +a future column model must explicitly map uncertainties to the selected rows. + +## Verification + +The desktop suite contains 592 assertions across precision (19), report (118), +popup (17), Data Tool layout (234), constraints (40), and physics (164). +The companion Tracker calibration fixture adds 17 checks. The computational +precision/report/constraint/physics tests also run through SwingJS in Chrome, +Firefox and WebKit (341 checks per browser). Browser interaction checks exercise +selection, resizing, fixed parameters, supplied sigma, and copying the report. +These local runs cover macOS and browser engines; they do not constitute new +native Windows/Linux runtime testing. + +### Compact and full copied reports + +Copy Fit Report exports the parameter table and the statistics shown on screen, with model, equation, variable units, and full-precision numeric cells. It omits ANOVA and advanced goodness-of-fit analysis. The adjacent `...` menu offers **Copy Full Fit Report**, also available in the parameter context menu. Both formats preserve manual/fixed parameter semantics and uncertainty units. Specified measurement uncertainty and identifiability warnings remain visible in the compact report when applicable. + +The uncertainty controls label the default **Unweighted - estimate from residuals**. Known uncertainty entry uses a wider field on a separate row. From the default mode, pixels start at 1; physical units start at one calibrated pixel, or the residual standard error if calibration is unavailable. A zero/undefined starting scatter leaves the entry blank. Switching between supplied physical and pixel modes converts the current value. Pixel presets are offered only in pixel mode. These starting values remain editable measurement-model choices, not claims about instrument accuracy. + +The data-uncertainty entry displays two significant digits (for example, `0.0025 m`). Its full stored precision remains in the tooltip and exported reports. Formatting and accepting unchanged display text do not round the uncertainty used in calculations. + +### Motion results from position fits + +An optional `FitMetadataProvider.getPositionComponent(xColumn, yColumn)` callback identifies position against time using host column identities. Its default is null, preserving existing hosts. For known line fits, velocity is A and its standard error is sigma_A. For known quadratics, velocity at t=0 is B with sigma_B, and constant acceleration is 2A with standard error 2*sigma_A. These are exact coefficient transformations; no new optimizer or covariance method is used. The units come from the corresponding polynomial coefficient units. Manual and fixed-parameter errors remain N/A. Arbitrary UserFunctions, other polynomial degrees, and unidentified column pairs do not receive motion labels. + +Derived motion results appear at the end of both copied report formats with full-precision numeric export. The window shows the original parameter table without duplicate velocity and acceleration rows. The reference time is the data's t=0, not necessarily the first selected observation. This feature does not compute velocity uncertainty at other times or propagate pixel errors into numerical derivative columns. + +At small window sizes the fit panel scrolls vertically, preserving a usable drawable graph above it. The reserved scrollbar width avoids changing the parameter layout when scrolling becomes necessary. Layout tests now measure the drawable area after axis gutters and verify that scrolling reaches the uncertainty controls. + +The on-screen residual summary uses one row: R-squared, SSE, and Residual SE. Points, free parameters, and degrees of freedom remain in copied reports but no longer consume display space. RMS remains beside Autofit. diff --git a/test/fit-uncertainty-details.md b/test/fit-uncertainty-details.md new file mode 100644 index 00000000..9cd77675 --- /dev/null +++ b/test/fit-uncertainty-details.md @@ -0,0 +1,414 @@ +# Understanding fit uncertainties: the statistics behind the result + +## Documentation index + +| Document | Contents | +|---|---| +| [Using fit results](fit-uncertainty-guide.md) | Short lab guide to fitted values, uncertainties, and reporting. | +| [Understanding the statistics](fit-uncertainty-details.md) **(this page)** | Statistical reasoning and a worked example. | +| [Bevington implementation](bevington-fit-uncertainties.md) | Profile-curvature method, code, and numerical limitations. | +| [Report definitions and integration](fit-report-physics.md) | Units, uncertainty models, and Tracker integration. | +| [Tests and documentation](README.md) | Regression test instructions and coverage. | + +> **A number from a fit is not the answer; it is a measurement with assumptions attached.** + +This is the middle layer of the Tracker fit-uncertainty documentation. + +- **Just using Tracker for a lab?** Start with the [short guide](fit-uncertainty-guide.md). +- **Want to know where the uncertainty comes from?** Read this document. +- **Reviewing or maintaining the implementation?** See the [Bevington-style implementation note](bevington-fit-uncertainties.md). + +This note assumes that you already know what a residual is and have seen least-squares fitting before. It develops the statistical ideas behind Tracker's reported coefficient standard errors without following the Java code line by line. + +## 1. From residuals to a measurement scale + +Suppose Tracker fits a function `f(x)` to measured values `y_i`. The residual for point `i` is + +```text +residual_i = y_i - f(x_i) +``` + +and the sum of squared residuals is + +```text +SSE = sum(residual_i^2). +``` + +Tracker currently treats all selected observations as having the same y uncertainty. There are two main ways to set that common uncertainty. + +### Estimate it from the residuals + +If no independent measurement uncertainty is supplied, Tracker estimates a common variance from the scatter about the fitted curve: + +```text +sigma_y^2 = SSE_min / df +``` + +where `df` is the residual degrees of freedom. For an ordinary full-rank fit with `n` observations and `p` independently determined fitted coefficients, + +```text +df = n - p. +``` + +More generally Tracker uses the numerical rank `r` of the free parameter directions, so + +```text +df = n - r. +``` + +This distinction matters when two adjustable coefficients cannot actually be determined independently from the data. + +Because this mode defines `sigma_y` from the fitted residuals themselves, + +```text +chi^2_min = SSE_min / sigma_y^2 = df +``` + +and therefore reduced chi-square is exactly one whenever the calculation is defined. It is one **by construction**, so it is not an independent goodness-of-fit test in this mode. + +### Supply the measurement uncertainty + +If an experiment provides an independent common uncertainty `sigma_y`, Tracker instead uses + +```text +chi^2 = SSE / sigma_y^2. +``` + +The best-fit coefficients do not change when the same positive uncertainty is assigned to every point: multiplying the objective by a constant does not move its minimum. The uncertainty scale and chi-square statistics do change. + +The pixel option is another way of supplying the same kind of common uncertainty. Tracker converts the selected pixel uncertainty to physical position units using the available calibration scale. It does not, in this calculation, propagate uncertainty in the ruler or calibration itself. + +## 2. Why moving a coefficient tells us its uncertainty + +Consider a straight-line fit + +```text +y = A*x + B. +``` + +The best-fit values of `A` and `B` occur at the minimum of the fit objective. To estimate the uncertainty in `A`, Tracker asks a practical question: + +> How far can I move `A` before the fit becomes noticeably worse, while still allowing the other fitted coefficients to adjust? + +Tracker therefore holds `A` at a nearby trial value, refits `B`, and calculates the resulting chi-square. Repeating this on both sides of the best-fit value traces the local **profile** of chi-square for `A`. + +Near a well-behaved minimum that profile is approximately quadratic: + +```text +chi^2_profile(A_hat + h) ~= chi^2_min + h^2 / sigma_A^2. +``` + +So a displacement of about one standard error corresponds locally to an increase + +```text +Delta chi^2 ~= 1. +``` + +Tracker estimates the local curvature using equal trial steps `delta` on either side: + +```text +D = chi^2_minus + chi^2_plus - 2*chi^2_min +sigma_A ~= delta * sqrt(2/D). +``` + +The factor of two appears because `D` contains the increases from **both** sides of the minimum. + +This is a symmetric local standard-error estimate. Tracker does not search independently for left and right confidence limits. + +## 3. Why the other coefficients are refitted + +Suppose the fitted model is still + +```text +y = A*x + B. +``` + +If the trial slope `A` becomes slightly larger, the intercept `B` can often shift to compensate. Holding `B` fixed would answer a different question: + +> How uncertain is `A` if `B` is already known exactly? + +Refitting `B` asks the experimentally relevant question: + +> How uncertain is `A` when both `A` and `B` must be inferred from these same data? + +This is why correlated fitted coefficients matter even when Tracker reports one standard error beside each coefficient. + +For a regular linear least-squares problem, the local profile-curvature result agrees with the usual covariance-matrix standard error when both calculations use the same measurement-uncertainty scale. The implementation intentionally retains the numerical profile/refit calculation. + +## 4. A reproducible straight-line example + +Take + +```text +x = [0, 1, 2, 3] +y = [1.1, 2.9, 4.9, 7.1] +``` + +and fit + +```text +y = A*x + B. +``` + +The fitted line is + +```text +A = 2 +B = 1. +``` + +The residuals are + +```text ++0.1, -0.1, -0.1, +0.1 +``` + +so + +```text +SSE_min = 0.04. +``` + +There are four observations and two independent fitted coefficients, giving + +```text +df = 4 - 2 = 2 +sigma_y^2 = 0.04 / 2 = 0.02 +sigma_y = sqrt(0.02) = 0.141421... +``` + +The value `0.02` is the **variance**; `0.141421...` is the standard deviation. + +### Profile the slope + +Let the trial slope be + +```text +A = 2 + h. +``` + +For every trial slope the intercept is refitted. A least-squares line with a free intercept passes through the mean point. Here + +```text +x_mean = 1.5 +y_mean = 4 +``` + +so + +```text +B = y_mean - A*x_mean + = 4 - (2+h)*1.5 + = 1 - 1.5*h. +``` + +The resulting profile SSE is + +```text +SSE_profile(A) = 0.04 + 5*h^2, +``` + +where + +```text +Sxx = sum((x-x_mean)^2) = 5. +``` + +Dividing by the fixed residual variance gives + +```text +chi^2_profile(A) + = (0.04 + 5*h^2)/0.02 + = 2 + 5*h^2/0.02. +``` + +Compare this with the profile expression from section 2: + +```text +h^2/sigma_A^2 = 5*h^2/0.02 +therefore sigma_A^2 = 0.02/5. +``` + +The slope standard error is therefore + +```text +sigma_A = sqrt(0.02/5) + = 0.063245553... +``` + +so the fitted slope may be reported approximately as + +```text +A = 2.000 +/- 0.063. +``` + +### Profile the intercept + +The corresponding straight-line result for the intercept is + +```text +sigma_B^2 = sigma_y^2 * (1/n + x_mean^2/Sxx) + = 0.02 * (1/4 + 1.5^2/5) + = 0.014 + +sigma_B = 0.1183216... +``` + +so the fitted intercept may be reported approximately as + +```text +B = 1.00 +/- 0.12. +``` + +The two coefficient uncertainties differ because slope and intercept affect the fitted line in different ways. + +### What if the intercept were known exactly? + +If `B` were held fixed at 1 instead of being refitted, the slope uncertainty would use `sum(x^2)=14` rather than `Sxx=5`: + +```text +sigma_A_fixed_B = sqrt(0.02/14) + = 0.0377964... +``` + +This comparison keeps the original variance, `0.02`, unchanged so that it +isolates the effect of treating the intercept as known exactly. + +If you actually fix `B=1` in Tracker's default residual-estimated mode, Tracker +also recalculates the variance. The line and SSE remain unchanged in this +example, but only one coefficient is now fitted: + +```text +df = 4 - 1 = 3 +sigma_y^2 = 0.04/3 +sigma_A = sqrt((0.04/3)/14) = 0.0308607... +``` + +The two calculations use different uncertainty scales. Neither smaller number +is a better estimate of the original problem, in which both coefficients had +to be determined from the data. + +## 5. Supplying a different measurement uncertainty + +Suppose the same four measurements have an independently justified common uncertainty + +```text +sigma_y = 0.20. +``` + +Then + +```text +sigma_y^2 = 0.04. +``` + +The best-fit line remains `A=2`, `B=1`, because every point still has equal weight. The coefficient uncertainties scale with the supplied uncertainty: + +```text +sigma_A = sqrt(0.04/5) = 0.0894427... +sigma_B = sqrt(0.04*0.70) = 0.1673320... +``` + +The fit statistics now have independent content: + +```text +chi^2 = SSE_min/sigma_y^2 = 1 +reduced chi^2 = 1/2 = 0.5. +``` + +The usual chi-square probability interpretation assumes independent Gaussian +measurement errors with the stated standard uncertainties and an adequate +model. For `df=2`, the chi-square survival probability is about `Q=0.607`. `Q` is the probability, assuming the model and uncertainty assumptions, of obtaining a chi-square at least this large from repeated data. It is **not** the probability that the model is true. + +## 6. When two coefficients cannot be determined separately + +Consider + +```text +y = (A+B)*x. +``` + +The data can determine `A+B`, but they cannot determine `A` and `B` separately. Increasing `A` while decreasing `B` by the same amount leaves every prediction unchanged. + +So two editable coefficients do not necessarily represent two independently measurable parameter directions. Tracker estimates the local numerical rank `r` of those directions and uses + +```text +df = n - r. +``` + +For four observations of this model, + +```text +p = 2 editable coefficients +r = 1 independent direction +df = 3. +``` + +Tracker reports a warning for dependent parameters and does not pretend that the individual `A` and `B` uncertainties are meaningful. + +The exact numerical rank algorithm is described in the implementation note. + +## 7. What this standard error does not include + +Tracker's current fit uncertainty model assumes that: + +- the x values are known well enough to treat as exact; +- residual measurement errors are independent; +- selected y measurements share a common uncertainty; +- the fitted model is an adequate local description of the data; +- the profile is sufficiently regular near its minimum for a symmetric local error to be useful. + +It does **not** automatically include uncertainty from: + +- ruler or spatial calibration; +- frame timing; +- lens distortion; +- motion outside the calibration plane; +- an inadequate physical model; +- correlations in derived velocity or acceleration columns. + +A small reported coefficient uncertainty therefore does not guarantee an accurate physical result. Conversely, a large uncertainty may make an inaccurate-looking result statistically compatible with an expected value simply because the experiment was imprecise. + +Residual plots and experimental choices still matter. + +## 8. From fitted coefficients to motion quantities + +For a line fit + +```text +x = A*t + B, +``` + +velocity is `A`, so its standard error is `sigma_A`. + +For a quadratic position fit + +```text +y = A*t^2 + B*t + C, +``` + +acceleration is + +```text +a = 2*A +``` + +with standard error + +```text +sigma_a = 2*sigma_A. +``` + +The coefficient `B` is velocity at `t=0`. Velocity at another time, + +```text +v(t) = 2*A*t + B, +``` + +depends on both `A` and `B`. Its uncertainty generally requires their covariance or an equivalent reparameterized/profile calculation. Tracker does not obtain that uncertainty by pretending the two coefficient errors are independent. + +## Where to go next + +If this is enough detail, return to the [short guide](fit-uncertainty-guide.md) and use the fit thoughtfully. + +If you want the exact numerical step-size rules, Java code path, rank calculation, perfect-fit behavior, and regression tests, continue to the [Bevington-style implementation note](bevington-fit-uncertainties.md). diff --git a/test/fit-uncertainty-guide.md b/test/fit-uncertainty-guide.md new file mode 100644 index 00000000..cf278509 --- /dev/null +++ b/test/fit-uncertainty-guide.md @@ -0,0 +1,224 @@ +# Understanding fit uncertainties + +## Documentation index + +| Document | Contents | +|---|---| +| [Using fit results](fit-uncertainty-guide.md) **(this page)** | Short lab guide to fitted values, uncertainties, and reporting. | +| [Understanding the statistics](fit-uncertainty-details.md) | Statistical reasoning and a worked example. | +| [Bevington implementation](bevington-fit-uncertainties.md) | Profile-curvature method, code, and numerical limitations. | +| [Report definitions and integration](fit-report-physics.md) | Units, uncertainty models, and Tracker integration. | +| [Tests and documentation](README.md) | Regression test instructions and coverage. | + +> **A number from a fit is not the answer; it is a measurement with assumptions attached.** + +Suppose Tracker reports a velocity of **2.000 +/- 0.063 m/s**. + +- **2.000 m/s** is the velocity calculated from the fitted line's slope. +- **0.063 m/s** is Tracker's estimate of how precisely that velocity was determined from the selected measurements and uncertainty model. + +The number after `+/-` is not a known mistake in the velocity. Tracker does not know the object's true velocity. A small uncertainty can accompany an inaccurate result if, for example, the distance calibration is wrong. + +This is the short guide for using fit uncertainties in a lab. + +- **Want the statistical reasoning and a worked example?** Continue to [the details](fit-uncertainty-details.md). +- **Reviewing the numerical implementation?** See the [Bevington-style implementation note](bevington-fit-uncertainties.md). + +## How Tracker finds the slope and its uncertainty + +For an object moving at constant velocity, position versus time follows a line: + +```text +x = A*t + B +``` + +`A` is the slope, which gives velocity. `B` is the position at `t=0`. + +Tracker chooses `A` and `B` to make the measured positions lie as close as possible to the fitted line. The vertical differences between the measurements and the line are called **residuals**. + +To estimate the uncertainty in `A`, Tracker tries a slightly steeper line and lets `B` readjust to give the best possible fit. It also tries a slightly shallower line. If even a small change in slope makes the fit much worse, the slope is tightly determined. If many nearby slopes fit almost equally well, the slope uncertainty is larger. + +There are two uncertainties to keep separate: + +- uncertainty in **each measured position**; +- uncertainty in the **velocity calculated from all of those positions**. + +They even have different units: metres and metres per second. + +Tracker calls a fitted coefficient's uncertainty its **standard error**. It is not a maximum possible error and it is not automatically a 95% confidence interval. + +## A small example + +Suppose four position measurements are + +| Time (s) | Position (m) | +|---|---| +| 0 | 1.1 | +| 1 | 2.9 | +| 2 | 4.9 | +| 3 | 7.1 | + +The fitted line is + +```text +x = 2*t + 1 +``` + +and Tracker reports approximately + +```text +Velocity A 2.000 +/- 0.063 m/s +Position at t=0 B 1.00 +/- 0.12 m +``` + +The points do not fall exactly on the line. Their scatter helps determine how precisely the slope and intercept are known. + +The full calculation, including residuals, degrees of freedom, and why the slope uncertainty is `0.063 m/s`, is worked out in [the statistics guide](fit-uncertainty-details.md). + +## Choosing the position uncertainty + +Tracker currently gives every selected point the same position uncertainty. You can choose how that common uncertainty is obtained. + +### Unweighted - estimate from residuals + +This is the default. Tracker gives all selected points equal weight and estimates their common uncertainty from how much they scatter about the fitted curve. + +This is useful when you do not have an independent estimate of the measurement uncertainty. + +An important consequence is that **reduced chi-square is exactly 1 by construction** whenever this calculation is defined. That value cannot independently confirm that the model is good. + +### Known uncertainty (data units) + +Use this when you have an independent estimate of the uncertainty of each measured position. + +For example, entering `0.20 m` means that you are assigning a standard uncertainty of `0.20 m` to every selected position. Because every point still has the same weight, the best-fit line does not move. The reported coefficient uncertainties and chi-square statistics do change. + +### Known uncertainty (pixels) + +This describes marking precision in image pixels. + +For example, with a calibration of `0.0025 m/pixel`: + +```text +1.0 pixel -> 0.0025 m +0.5 pixel -> 0.00125 m +``` + +One pixel is a reasonable starting point, not a universal truth. You must judge how precisely the object can actually be marked. + +This setting describes marking uncertainty only. It does not include uncertainty in the ruler, camera geometry, timing, or calibration itself. + +## A precise result can still be wrong + +Suppose a class measures gravitational acceleration and obtains + +```text +g = 10.10 +/- 0.03 m/s^2. +``` + +The small uncertainty says the tracked points determine that fitted value very consistently. It does **not** say that the calibration was correct or that the physical model included every important effect. + +Calibration, timing, lens distortion, motion outside the calibration plane, or a poor model can produce a precise but inaccurate result. + +Likewise, careless marking can increase the scatter and make the reported uncertainty larger. An inaccurate result may then appear to agree with an expected value simply because the experiment was imprecise. + +**Precision and accuracy are different questions.** + +## What R-squared and reduced chi-square tell you + +Tracker's full fit report includes several statistics. Two are especially useful in introductory work. + +### R-squared + +R-squared describes how much of the observed variation the fitted curve accounts for. + +A value close to one means the curve follows the data closely. It does **not** prove that: + +- the physical model is correct; +- the calibration is correct; +- the result is accurate; +- the measurement uncertainty is realistic. + +A high R-squared is therefore useful, but it is not a certificate of experimental correctness. + +### Reduced chi-square + +When you supply an independent measurement uncertainty, reduced chi-square compares the observed residual scatter with that stated uncertainty. + +As a rough guide: + +- near 1: scatter is comparable to what the uncertainty model predicts; +- much greater than 1: more scatter than expected; +- much less than 1: less scatter than expected. + +Those outcomes can have several causes. Inspect the residuals, measurements, calibration, and model before drawing conclusions. + +In the default residual-estimated mode, reduced chi-square is 1 by construction, so Tracker does not treat it as an independent fit check. The chi-square probability `Q` is shown as N/A in that mode. + +The [statistics guide](fit-uncertainty-details.md) explains why. + +## Getting velocity and acceleration from position fits + +For a line fit + +```text +x = A*t + B +``` + +velocity is `A`, and its standard error is the standard error reported for `A`. + +For a quadratic position fit + +```text +y = A*t^2 + B*t + C +``` + +acceleration is + +```text +a = 2*A +``` + +and its standard error is + +```text +sigma_a = 2*sigma_A. +``` + +For example, + +```text +A = -4.989 +/- 0.023 m/s^2 +``` + +gives + +```text +a = -9.978 +/- 0.046 m/s^2. +``` + +`B` gives the velocity at `t=0`, which need not be the first selected frame. + +Tracker's pixel-uncertainty setting applies to the fitted position measurements. It does not independently assign uncertainties to Tracker's separate velocity and acceleration data columns, because those derived columns reuse measurements from several frames. + +## Writing the result in a lab report + +Keep full precision during calculations and round when reporting the final result. Tracker displays two significant digits in a positive uncertainty and rounds the fitted value to the same decimal place. Copied numeric results retain full precision. + +Record: + +- the selected time interval; +- the fitted equation; +- units; +- the uncertainty choice; +- the fitted result and its uncertainty. + +Also inspect the graph and residuals rather than reporting fit statistics alone. + +`N/A` means that an uncertainty estimate is unavailable. This can occur when parameters are entered manually, a coefficient is held fixed, or the data do not independently determine a coefficient. + +Use **... -> Copy Full Fit Report** when you need the additional statistics. + +## Want to know why? + +The next step is [Understanding fit uncertainties: the statistics behind the result](fit-uncertainty-details.md). It explains residual variance, degrees of freedom, profile curvature, why other coefficients must be refitted, and the `2.000 +/- 0.063 m/s` example in detail. diff --git a/test/org/opensourcephysics/tools/CurveFitConstraintTest.java b/test/org/opensourcephysics/tools/CurveFitConstraintTest.java new file mode 100644 index 00000000..c5220f13 --- /dev/null +++ b/test/org/opensourcephysics/tools/CurveFitConstraintTest.java @@ -0,0 +1,127 @@ +package org.opensourcephysics.tools; + +import java.awt.Component; +import java.awt.Container; +import java.awt.Window; +import javax.swing.*; +import org.opensourcephysics.display.Dataset; + +/** Exercises constraint edits, identifiability, and constrained short datasets. */ +public class CurveFitConstraintTest { + static int passed; + + public static void main(String[] args) throws Exception { + SwingUtilities.invokeAndWait(() -> { + try { + runTests(); + System.out.println("Passed: " + passed); + } finally { + for (Window window : Window.getWindows()) window.dispose(); + } + }); + } + + static void runTests() { + Dataset noisy = data(new double[] {0,1,2,3}, new double[] {1.1,2.9,4.9,7.1}); + DatasetCurveFitter fitter = line(noisy); + close(fitter.getUncertainty(0), Math.sqrt(.02/5), "unconstrained slope uncertainty"); + close(fitter.getUncertainty(1), Math.sqrt(.014), "unconstrained intercept uncertainty"); + JTable table = table(fitter); + toggle(table, 0); + check(fitter.isAutoFit(), "fixing slope preserves Autofit"); + check(Double.isNaN(fitter.getUncertainty(0)), "fixed slope has no uncertainty"); + close(fitter.getUncertainty(1), Math.sqrt(.04/3/4), "free intercept uncertainty recomputed immediately"); + check(labels(fitter).contains("Free parameters: 1"), "free count refreshed immediately"); + check(labels(fitter).contains("Degrees of freedom: 3"), "degrees of freedom refreshed immediately"); + String report = report(fitter); + check(report.contains("A\t2.0\tN/A\t\tYes"), "export identifies fixed slope"); + close(CurveFitReportTest.cell(report, "B", 2), Math.sqrt(.04/3/4), "export uses new intercept uncertainty"); + + toggle(table, 1); + check(labels(fitter).contains("Free parameters: 0"), "all fixed updates free count"); + check(labels(fitter).contains("Degrees of freedom: 4"), "all fixed updates degrees of freedom"); + check(Double.isNaN(fitter.getUncertainty(0)) && Double.isNaN(fitter.getUncertainty(1)), "all fixed clears uncertainties"); + toggle(table, 1); + close(fitter.getUncertainty(1), Math.sqrt(.04/3/4), "unfixing intercept recomputes uncertainty"); + + fitter.setData(data(new double[] {1}, new double[] {2.9}), true); + close(((Number)table.getValueAt(0,2)).doubleValue(), 2, "fixed slope remains visible with one point"); + close(((Number)table.getValueAt(1,2)).doubleValue(), .9, "fitted intercept visible with one point"); + close(fitter.fit(fitter.fit), 0, "one free parameter and one point has finite RMS"); + check(Double.isNaN(fitter.getUncertainty(1)), "no residual degrees of freedom means unknown uncertainty"); + close(CurveFitReportTest.cell(report(fitter), "B", 1), .9, "screen and exported intercept agree"); + toggle(table, 0); + check(Double.isNaN(((Number)table.getValueAt(0,2)).doubleValue()), "two free parameters still require two points"); + check(Double.isNaN(fitter.fit(fitter.fit)), "underdetermined fit retains unavailable RMS"); + + fitter = line(noisy); + fitter.setAutoFit(false); + fitter.fit.setParameterValue(0, 5); + fitter.fit(fitter.fit); + toggle(table(fitter), 0); + check(!fitter.isAutoFit(), "constraint edit preserves manual mode"); + close(fitter.fit.getParameterValue(0), 5, "manual constraint does not alter slope"); + close(fitter.fit.getParameterValue(1), 1, "manual constraint does not optimize intercept"); + check(report(fitter).contains("Manual parameters"), "manual mode retained in report"); + + fitter = line(data(new double[] {0,1,2,3}, new double[] {1,3,5,7})); + close(fitter.getUncertainty(0), 0, "identifiable perfect-fit slope retains zero uncertainty"); + close(fitter.getUncertainty(1), 0, "identifiable perfect-fit intercept retains zero uncertainty"); + check(report(fitter).contains("A\t2.0\t0.0\t\tNo"), "legitimate zero uncertainty exported"); + for (double[] y : new double[][] {{.1,1.9,3.9,6.1}, {0,2,4,6}}) { + fitter = line(data(new double[] {0,1,2,3}, y)); + UserFunction redundant = new UserFunction("Redundant"); + redundant.setParameters(new String[] {"A","B"}, new double[] {1,1}, null); + redundant.setExpression("(A+B)*x", new String[] {"x"}); + // Exercise the same optimizer path with a deliberately non-identifiable model. + fitter.fit = redundant; + fitter.fit(redundant, true); + check(Double.isNaN(fitter.getUncertainty(0)), "unidentifiable A uncertainty unavailable"); + check(Double.isNaN(fitter.getUncertainty(1)), "unidentifiable B uncertainty unavailable"); + String[] rows = report(fitter).split("\n"); + for (String row : rows) { + if (row.startsWith("A\t") || row.startsWith("B\t")) + check(CurveFitReportTest.splitTabs(row)[2].equals("N/A"), "unidentifiable uncertainty exported as N/A"); + } + } + } + + static DatasetCurveFitter line(Dataset data) { + DatasetCurveFitter fitter = new DatasetCurveFitter(data, new FitBuilder(null)); + fitter.selectFit(fitter.getPolyFitNameOfDegree(1)); + fitter.setActiveAndFit(true); + return fitter; + } + static Dataset data(double[] x, double[] y) { + Dataset data = new Dataset(); data.setXYColumnNames("t", "y"); data.append(x,y); return data; + } + static JTable table(DatasetCurveFitter fitter) { + return (JTable)((JScrollPane)fitter.splitPane.getRightComponent()).getViewport().getView(); + } + static void toggle(JTable table, int row) { + if (org.opensourcephysics.display.OSPRuntime.isJS) { + // This numerical fixture has no DOM. Live browser checkbox editing + // is exercised separately in the attached Data Tool UI test. + check(table.isCellEditable(row,1), "fixed checkbox editable"); + table.getModel().setValueAt(!(Boolean)table.getValueAt(row,1),row,1); + } else { + check(table.editCellAt(row,1), "fixed checkbox editable"); + ((JCheckBox)table.getEditorComponent()).doClick(); + } + } + static String report(DatasetCurveFitter fitter) { + int count=fitter.fit.getParameterCount(); boolean[] fixed=new boolean[count]; double[] sigma=new double[count]; + for(int i=0;i{try{ + Dataset data=new Dataset(); data.setXYColumnNames("t","y"); + for(int i=0;i<16;i++){double t=i/30.0;data.append(t,-1980*t*t+1067*t-3.9+0.4*Math.sin(i));} + DataTool tool=new DataTool(data); tool.addNotify(); + DataToolTab tab=tool.getTab(0); tab.checkGUI(); + tab.setFitMetadataProvider(new FitMetadataProvider() { + public String getUnits(String column){return column.equals("t")?"s":"m";} + public double getYUnitsPerPixel(String column){return Double.NaN;} + public String getPositionComponent(String x,String y){return x.equals("t") && y.equals("y")?"y":null;} + }); + DatasetCurveFitter fitter=tab.getCurveFitter(); + tab.showFitterAction.actionPerformed(new ActionEvent(tab,0,fitter.getPolyFitNameOfDegree(2))); + java.lang.reflect.Field sf=DatasetCurveFitter.class.getDeclaredField("statisticsPanel");sf.setAccessible(true);JPanel stats=(JPanel)sf.get(fitter); + JScrollPane scroll=(JScrollPane)fitter.splitPane.getRightComponent();JTable table=(JTable)scroll.getViewport().getView(); + for(int level:new int[]{0,2}){ + tool.setFontLevel(level); + for(int[] size:new int[][]{{950,700},{1600,1000},{950,700}}){ + tool.setSize(size[0],size[1]); tool.validate(); + tab.splitPanes[0].setDividerLocation(0.7); + for(int i=0;i<5;i++){tool.validate();layout(tool.getContentPane());} + int stableDivider=tab.splitPanes[1].getDividerLocation(); + int stableOrientation=fitter.splitPane.getOrientation(); + for(int count:new int[]{1,2,3,8,15,16,1,16}){ + Dataset selected=new Dataset();selected.setXYColumnNames("t","y"); + for(int j=0;j=parameterBottom.y+parameterBottom.height,"parameter information above statistics"); + check(new Rectangle(0,0,fitter.getWidth(),fitter.getHeight()).contains(statsBounds),"all statistics visible in parent fit area"); + check(stats.getHeight()==stats.getPreferredSize().height,"statistics remain compact"); + check(stats.getComponentCount()==3,"only three statistics displayed"); + int statY=stats.getComponent(0).getY(); + for(Component label:stats.getComponents())check(label.getY()==statY,"statistics share a single row"); + java.lang.reflect.Field uf=DatasetCurveFitter.class.getDeclaredField("uncertaintyPanel");uf.setAccessible(true);JPanel uncertainty=(JPanel)uf.get(fitter); + Rectangle uncertaintyBounds=SwingUtilities.convertRectangle(uncertainty,new Rectangle(0,0,uncertainty.getWidth(),uncertainty.getHeight()),fitter); + check(new Rectangle(0,0,fitter.getWidth(),fitter.getHeight()).contains(uncertaintyBounds),"uncertainty controls visible in fit area"); + for (String fieldName : new String[]{"uncertaintyChoice", "uncertaintyValue"}) { + java.lang.reflect.Field field=DatasetCurveFitter.class.getDeclaredField(fieldName);field.setAccessible(true); + JComboBox control=(JComboBox)field.get(fitter); + Rectangle bounds=SwingUtilities.convertRectangle(control,new Rectangle(0,0,control.getWidth(),control.getHeight()),fitter); + check(new Rectangle(0,0,fitter.getWidth(),fitter.getHeight()).contains(bounds),"uncertainty input fully visible: "+fieldName); + if(fieldName.equals("uncertaintyValue"))check(control.getWidth()>=control.getFontMetrics(control.getFont()).stringWidth("0.0000000000"),"room to enter fractional physical units"); + } + check(tab.splitPanes[1].getTopComponent().getHeight()>100,"plot retains usable height"); + check(tab.plot.getHeight()-tab.plot.getTopGutter()-tab.plot.getBottomGutter()>=140,"drawable graph retains at least 140 pixels"); + check(tab.fitScroller.getViewport().getView()==fitter,"fit controls reachable through scroll viewport"); + tab.fitScroller.getVerticalScrollBar().setValue(tab.fitScroller.getVerticalScrollBar().getMaximum()); + layout(tool.getContentPane()); + check(fitter.getVisibleRect().contains(uncertaintyBounds),"scrolling reaches uncertainty controls"); + tab.fitScroller.getVerticalScrollBar().setValue(0); + check(Math.abs(fitter.getHeight()-fitter.prepareFitLayout(fitter.getWidth()))<=4,"fit area stays at required height"); + if(args.length>0){BufferedImage image=new BufferedImage(tool.getContentPane().getWidth(),tool.getContentPane().getHeight(),BufferedImage.TYPE_INT_RGB);Graphics2D g=image.createGraphics();tool.getContentPane().printAll(g);g.dispose();javax.imageio.ImageIO.write(image,"png",new java.io.File(args[0]+"/data-tool-"+size[0]+"-font"+level+".png"));} + } + } + System.out.println("Passed: "+passed); success=true; + }catch(Throwable e){e.printStackTrace();}finally{System.exit(success ? 0 : 1);}}); + System.exit(0); + } + static void layout(Container c){c.doLayout();for(Component child:c.getComponents())if(child instanceof Container)layout((Container)child);} + static void check(boolean b,String message){if(!b){System.err.println("FAIL: "+message);System.exit(1);}passed++;System.out.println("PASS: "+message);} +} diff --git a/test/org/opensourcephysics/tools/CurveFitPhysicsTest.java b/test/org/opensourcephysics/tools/CurveFitPhysicsTest.java new file mode 100644 index 00000000..78176c73 --- /dev/null +++ b/test/org/opensourcephysics/tools/CurveFitPhysicsTest.java @@ -0,0 +1,185 @@ +package org.opensourcephysics.tools; + +import org.opensourcephysics.display.*; +import org.opensourcephysics.media.core.VideoIO; +import javax.swing.SwingUtilities; + +/** Scientific and clipboard regression checks, also transpiled for SwingJS. */ +public class CurveFitPhysicsTest { + static int passed; + static void check(boolean ok,String name) { if(!ok)throw new AssertionError(name);passed++; } + static void near(double a,double b,String name) { check(Math.abs(a-b)<1e-7*Math.max(1,Math.abs(b)),name+": "+a+" != "+b); } + public static void main(String[] args) throws Exception { + try { if(OSPRuntime.isJS)run();else SwingUtilities.invokeAndWait(()->run()); } + catch(Throwable t) { t.printStackTrace();if(!OSPRuntime.isJS)System.exit(1);throw new RuntimeException(t); } + System.out.println("Passed: "+passed); + if(!OSPRuntime.isJS)System.exit(0); + } + static void run() { + OSPRuntime.setPreferredDecimalSeparator("."); + check(DatasetCurveFitter.formatDataUncertainty(.002518020744556).equals("0.0025"),"data uncertainty two significant digits"); + check(DatasetCurveFitter.formatDataUncertainty(1).equals("1.0"),"pixel trailing zero"); + check(DatasetCurveFitter.formatDataUncertainty(.000001234).equals("1.2E-6"),"small uncertainty compact scientific notation"); + check(DatasetCurveFitter.formatDataUncertainty(.00999).equals("0.010"),"uncertainty rounding carry"); + OSPRuntime.setPreferredDecimalSeparator(","); + check(DatasetCurveFitter.formatDataUncertainty(.002518).equals("0,0025"),"localized uncertainty display"); + OSPRuntime.setPreferredDecimalSeparator("."); + KnownPolynomial motion = new KnownPolynomial(new double[]{-.0098,2.688,-4.989}); + double[] motionErrors = {.023,.013,.0018}; + String[][] derived = CurveFitReport.motionResults(motion,null,motionErrors,true,"s","m","y"); + check(derived.length==2,"quadratic provides velocity at zero and acceleration"); + check(derived[0][0].equals("y velocity at t=0"),"reference time explicit"); + near(Double.parseDouble(derived[0][1]),2.688,"velocity is B"); + near(Double.parseDouble(derived[0][2]),.013,"velocity error is sigma B"); + near(Double.parseDouble(derived[1][1]),-9.978,"acceleration is twice A"); + near(Double.parseDouble(derived[1][2]),.046,"acceleration error is twice sigma A"); + check(derived[0][3].equals("m/s") && derived[1][3].equals("m/s^2"),"motion result units"); + String copied = CurveFitReport.appendMotionResults("",derived); + check(copied.contains("y acceleration\t-9.978\t0.046\tm/s^2"),"motion values exported once at full precision"); + check(CurveFitReport.motionResults(motion,null,motionErrors,true,"s","m",null).length==0,"no motion labels without host metadata"); + check(CurveFitReport.motionResults(new UserFunction("Arbitrary"),null,null,true,"s","m","y").length==0,"no guessed user-function motion"); + check(CurveFitReport.motionResults(new KnownPolynomial(new double[]{1,2,3,4}),null,null,true,"s","m","y").length==0,"no constant acceleration for cubic"); + derived=CurveFitReport.motionResults(motion,new boolean[]{true,false,false},motionErrors,true,"s","m","y"); + check(derived[1][2].equals("N/A") && derived[1][4].equals("Yes"),"fixed acceleration has no estimated error"); + derived=CurveFitReport.motionResults(motion,null,motionErrors,false,"s","m","y"); + check(derived[0][2].equals("N/A") && derived[1][2].equals("N/A"),"manual motion does not reuse fitted errors"); + derived=CurveFitReport.motionResults(new KnownPolynomial(new double[]{7,-2}),null,new double[]{.03,.2},true,"s","m","x"); + check(derived.length==1 && derived[0][0].equals("x velocity"),"line has velocity only"); + near(Double.parseDouble(derived[0][1]),-2,"line velocity sign and ordering"); + near(Double.parseDouble(derived[0][2]),.03,"line velocity uncertainty"); + near(motion.getParameterValue(0),-4.989,"derivation preserves coefficients"); + + DatasetManager manager=new DatasetManager();manager.setXPointsLinked(true); + manager.setXYColumnNames(0,"t","x");manager.append(0,new double[]{0,1,2,3},new double[]{1.1,2.9,4.9,7.1}); + manager.setXYColumnNames(1,"t","v_{x}");manager.append(1,new double[]{0,1,2,3},new double[]{2,2,2,2}); + DataTable table=new DataTable();table.add(manager.model); + table.refreshTable(DataTable.MODE_CREATE); + String delimiter=VideoIO.getDelimiter(); + for(String delim:new String[]{"\t",","}) { + VideoIO.setDelimiter(delim); + for(boolean formatted:new boolean[]{false,true}) { + table.setUnits("t",null,null);table.setUnits("x",null,null);table.setUnits("v_{x}",null,null); + String bare=table.getData(formatted).toString(); + check(!bare.substring(0,bare.indexOf('\n')).contains("("),"no units"); + check(bare.startsWith("t"+delim+"x"+delim+"vx"),"unitless headers preserve legacy subscript normalization"); + table.setUnits("t"," s",null);table.setUnits("x"," m",null);table.setUnits("v_{x}"," m/s²",null); + String full=table.getData(formatted).toString(); + check(full.startsWith("t (s)"+delim+"x (m)"+delim+"v_x (m/s^2)"),"all unit headers "+delim+formatted); + check(full.substring(full.indexOf('\n')).equals(bare.substring(bare.indexOf('\n'))),"numeric cells unchanged "+formatted); + table.setUnits("x",null,null); + check(table.getData(formatted).toString().startsWith("t (s)"+delim+"x"+delim),"mixed headers"); + DatasetManager[] pasted=DataTool.parseData(full,null); + check(pasted!=null,"export parses"); + DataTable again=new DataTable();again.add(pasted[0].model);again.refreshTable(DataTable.MODE_CREATE); + String twice=again.getData(false).toString(); + check(!twice.contains("(m) (m)") && twice.contains("x (m)"),"round trip no duplicate"); + } + } + VideoIO.setDelimiter(delimiter); + check(ExportText.header("x (m)","m").equals("x (m)"),"known suffix not doubled"); + check(ExportText.ascii("A*t^{2} + B*t + C").equals("A*t^2 + B*t + C"),"plain equation"); + for(int degree=0;degree<=3;degree++) { + KnownPolynomial polynomial=new KnownPolynomial(new double[degree+1]); + for(int i=0;i<=degree;i++) { + int power=degree-i;polynomial.setParameterValue(i,7); + String expected=power==0?"m":power==1?"m/s":"m/s^"+power; + check(CurveFitReport.coefficientUnits(polynomial,i,"s","m").equals(expected),"polynomial degree "+degree+" term "+power); + check(CurveFitReport.coefficientUnits(polynomial,i,null,"m").equals(power==0?"m":""),"unknown x unit"); + check(CurveFitReport.coefficientUnits(polynomial,i,"s",null).isEmpty(),"unknown y unit"); + } + } + UserFunction custom=new UserFunction("custom");custom.setParameters(new String[]{"A"},new double[]{1},null);custom.setExpression("A*x",new String[]{"x"}); + check(CurveFitReport.coefficientUnits(custom,0,"s","m").isEmpty(),"user function units not guessed"); + Dataset data=CurveFitConstraintTest.data(new double[]{0,1,2,3},new double[]{1.1,2.9,4.9,7.1}); + DatasetCurveFitter fitter=CurveFitConstraintTest.line(data); + near(fitter.fit.getParameterValue(0),2,"default slope");near(fitter.fit.getParameterValue(1),1,"default intercept"); + double[] stats=CurveFitReport.statistics(fitter.fit,data,null,true); + FitUncertainty estimated=FitUncertainty.estimated();double[] chi=estimated.statistics(stats,true,Double.NaN); + near(chi[0],Math.sqrt(.02),"estimated sigma");near(chi[1],2,"default chi df");near(chi[2],1,"default reduced chi");check(Double.isNaN(chi[3]),"default Q unavailable"); + double oldError=fitter.getUncertainty(0),slope=fitter.fit.getParameterValue(0),intercept=fitter.fit.getParameterValue(1); + fitter.setUncertaintyModel(FitUncertainty.CONSTANT,.2); + check(fitter.fit.getParameterValue(0)==slope && fitter.fit.getParameterValue(1)==intercept,"constant sigma leaves coefficients bitwise unchanged"); + near(fitter.getUncertainty(0),oldError*.2/Math.sqrt(.02),"profile uncertainty uses supplied scale"); + chi=fitter.getUncertaintyModel().statistics(stats,true,Double.NaN); + near(chi[0],.2,"supplied sigma not rescaled");near(chi[1],1,"specified chi");near(chi[2],.5,"specified reduced chi");near(chi[3],Math.exp(-.5),"Q df2"); + near(FitUncertainty.survival(3.841458820694124,1),.05,"Q df1"); + near(FitUncertainty.survival(18.307038053275146,10),.05,"Q df10"); + near(FitUncertainty.survival(0,3),1,"Q zero");near(FitUncertainty.survival(1000,2),Math.exp(-500),"Q extreme"); + fitter.setUncertaintyModel(FitUncertainty.CONSTANT,Double.NaN); + check(Double.isNaN(fitter.getUncertainty(0)),"invalid supplied sigma no estimated fallback"); + chi=fitter.getUncertaintyModel().statistics(stats,true,Double.NaN);check(Double.isNaN(chi[1]),"invalid chi unavailable"); + fitter.setAutoFit(false);fitter.setUncertaintyModel(FitUncertainty.CONSTANT,.2); + check(!fitter.isAutoFit() && fitter.fit.getParameterValue(0)==slope && Double.isNaN(fitter.getUncertainty(0)),"manual behavior retained"); + for(double pixels:new double[]{.5,1,1.5,2,2.5,3,.73}) { + FitUncertainty model=new FitUncertainty(FitUncertainty.PIXELS,pixels); + near(model.sigma(Double.NaN,.01),pixels*.01,"fractional pixel conversion"); + double[] result=model.statistics(stats,true,.01); + near(result[1],stats[3]/Math.pow(pixels*.01,2),"pixel chi"); + check(Double.isNaN(model.sigma(1,Double.NaN)),"derived quantity has no pixel conversion"); + } + UserFunction redundant=new UserFunction("RedundantPhysics");redundant.setParameters(new String[]{"A","B"},new double[]{1,1},null);redundant.setExpression("(A+B)*x",new String[]{"x"}); + stats=CurveFitReport.statistics(redundant,data,null,true);near(stats[1],2,"nominal free count");near(stats[8],1,"rank one");near(stats[2],3,"rank df"); + chi=estimated.statistics(stats,true,Double.NaN);near(chi[1],3,"rank default chi"); + stats=CurveFitReport.statistics(fitter.fit,data,new boolean[]{true,false},true);near(stats[8],1,"fixed rank");near(stats[2],3,"fixed df"); + Dataset exact=CurveFitConstraintTest.data(new double[]{0,1,2,3},new double[]{1,3,5,7}); + fitter=CurveFitConstraintTest.line(exact);stats=CurveFitReport.statistics(fitter.fit,exact,null,true); + chi=estimated.statistics(stats,true,Double.NaN);near(chi[0],0,"perfect residual sigma zero");check(Double.isNaN(chi[1]),"perfect estimated chi undefined"); + fitter.setUncertaintyModel(FitUncertainty.CONSTANT,.2);check(fitter.getUncertainty(0)>0,"perfect fit known noise positive profile error"); + chi=fitter.getUncertaintyModel().statistics(stats,true,Double.NaN);near(chi[1],0,"perfect specified chi zero");near(chi[3],1,"perfect specified Q one"); + // Existing UserFunction profile-curvature path, including its scale. + fitter=CurveFitConstraintTest.line(data); + UserFunction exponential=new UserFunction("PhysicsExp"); + exponential.setParameters(new String[]{"A"},new double[]{.4},null); + exponential.setExpression("exp(A*x)",new String[]{"x"}); + fitter.fit=exponential;fitter.fit(exponential,true); + double originalA=fitter.fit.getParameterValue(0),profile=fitter.getUncertainty(0); + double residualSigma=CurveFitReport.statistics(fitter.fit,data,null,true)[6]; + fitter.setUncertaintyModel(FitUncertainty.CONSTANT,residualSigma*2); + check(fitter.fit.getParameterValue(0)==originalA,"user function coefficient unchanged by scale"); + check(profile>0 && Math.abs(fitter.getUncertainty(0)/profile-2)<.02,"user function profile errors scale without covariance replacement"); + fitter.fit=redundant;fitter.fit(redundant,true); + check(Double.isNaN(fitter.getUncertainty(0)) && Double.isNaN(fitter.getUncertainty(1)),"redundant supplied-sigma profile errors remain unavailable"); + + DataTool tool=new DataTool(manager);DataToolTab tab=tool.getTab(0);tab.checkGUI(); + final double[] scale={.01}; + tab.setFitMetadataProvider(new FitMetadataProvider() { + public String getUnits(String column) { return column.equals("t")?"s":column.equals("x")?"m":null; } + public double getYUnitsPerPixel(String column) { return column.equals("x")?scale[0]:Double.NaN; } + }); + tab.setWorkingColumns("t","x"); + fitter=tab.getCurveFitter();fitter.selectFit(fitter.getPolyFitNameOfDegree(1));fitter.setActiveAndFit(true); + double measuredSlope=fitter.fit.getParameterValue(0); + fitter.selectUncertaintyMode(FitUncertainty.CONSTANT); + near(fitter.getUncertaintyModel().value,.01,"physical default is one calibrated pixel"); + fitter.setUncertaintyModel(FitUncertainty.CONSTANT,.001); + fitter.selectUncertaintyMode(FitUncertainty.PIXELS); + near(fitter.getUncertaintyModel().value,.1,"physical to pixel switch preserves scale"); + fitter.selectUncertaintyMode(FitUncertainty.CONSTANT); + near(fitter.getUncertaintyModel().value,.001,"pixel to physical switch preserves scale"); + fitter.selectUncertaintyMode(FitUncertainty.ESTIMATED); + fitter.selectUncertaintyMode(FitUncertainty.PIXELS); + near(fitter.getUncertaintyModel().value,1,"pixel default is one pixel"); + check(fitter.fit.getParameterValue(0)==measuredSlope,"mode switches preserve coefficient"); + near(fitter.getYUnitsPerPixel(),.01,"host position calibration reaches fitter: "+fitter.getData().getYColumnName()); + for(double pixels:new double[]{.5,1,1.5,2,2.5,3,.73}) { + fitter.setUncertaintyModel(FitUncertainty.PIXELS,pixels); + near(fitter.getUncertaintyModel().sigma(Double.NaN,fitter.getYUnitsPerPixel()),pixels*.01,"host fractional pixel scale"); + check(fitter.fit.getParameterValue(0)==measuredSlope,"pixel choices preserve fitted coefficient"); + } + scale[0]=.02;fitter.refreshUncertaintyModel();near(fitter.getYUnitsPerPixel(),.02,"live calibration refresh"); + tab.setWorkingColumns("t","v_{x}"); + check(Double.isNaN(fitter.getYUnitsPerPixel()),"derived variable does not inherit position calibration"); + fitter.setUncertaintyModel(FitUncertainty.PIXELS,1); + check(Double.isNaN(fitter.getUncertainty(0)),"ineligible pixel sigma not applied"); + // This fixture is disposable; do not open a modal save prompt during cleanup. + tab.tabChanged(false); + tool.dispose(); + fitter=CurveFitConstraintTest.line(exact); + String report=CurveFitReport.create(fitter.fit,exact,null,new double[]{.1,.1},true,true,"s","m",estimated,Double.NaN); + check(report.contains("X variable\tt (s)") && report.contains("Y variable\ty (m)"),"fit variable units"); + check(report.contains("Parameter\tCoefficients\tStandard Error\tUnits\tFixed"),"coefficient units column"); + check(report.contains("R Square (centered)"),"centered R2 retained"); + report=CurveFitReport.create(redundant,data,null,null,true,true,"s","m",estimated,Double.NaN); + check(report.contains("not independently identifiable"),"rank warning exposed"); + } +} diff --git a/test/org/opensourcephysics/tools/CurveFitPopupTest.java b/test/org/opensourcephysics/tools/CurveFitPopupTest.java new file mode 100644 index 00000000..dd75d5b0 --- /dev/null +++ b/test/org/opensourcephysics/tools/CurveFitPopupTest.java @@ -0,0 +1,96 @@ +package org.opensourcephysics.tools; + +import java.awt.Window; +import java.awt.event.InputEvent; +import java.awt.event.MouseEvent; +import javax.swing.*; +import javax.swing.table.TableCellEditor; +import org.opensourcephysics.display.Dataset; + +/** Context-menu gestures must never initialize a parameter editor. */ +public class CurveFitPopupTest { + static int passed; + public static void main(String[] args) throws Exception { + SwingUtilities.invokeAndWait(() -> { + try { + Dataset data = new Dataset(); + data.append(new double[] {0,1,2,3}, new double[] {1,3,5,7}); + DatasetCurveFitter fitter = new DatasetCurveFitter(data, new FitBuilder(null)); + fitter.fit = new KnownPolynomial(new double[] {1,2}); + fitter.setAutoFit(true); + fitter.uncertainties = new double[] {.05, .01}; + String labels = labels(fitter); + check(!labels.contains("Points:"), "point count omitted from display"); + check(!labels.contains("Degrees of freedom:") && !labels.contains("Free parameters:"), "parameter counts omitted from display"); + check(labels.contains("R-squared: 1.0000"), "R-squared visible"); + check(labels.contains("SSE: 0.0000"), "SSE visible"); + check(labels.contains("Residual SE: 0.0000"), "residual standard error visible"); + boolean[] allowEditor = {false}; + DatasetCurveFitter.ParamTable table = fitter.new ParamTable(fitter.new ParamTableModel()) { + @Override public TableCellEditor getCellEditor(int row, int column) { + if (!allowEditor[0]) throw new AssertionError("Context click initialized editor"); + DefaultCellEditor editor = new DefaultCellEditor(new JTextField()); + editor.setClickCountToStart(1); + return editor; + } + }; + for (int column : new int[] {1,2}) { + reject(table, column, MouseEvent.BUTTON3, 0, true, "right-click popup"); + reject(table, column, MouseEvent.BUTTON3, 0, false, "right-click before popup release"); + reject(table, column, MouseEvent.BUTTON1, InputEvent.CTRL_DOWN_MASK, true, "Control-click popup"); + if (org.opensourcephysics.display.OSPRuntime.isMac()) { + reject(table, column, MouseEvent.BUTTON1, InputEvent.CTRL_DOWN_MASK, false, "macOS Control-click"); + } else { + allowEditor[0] = true; + MouseEvent controlClick = new MouseEvent(table, MouseEvent.MOUSE_PRESSED, 0, + InputEvent.CTRL_DOWN_MASK, 5, 5, 1, false, MouseEvent.BUTTON1); + check(table.editCellAt(0, column, controlClick), "non-popup Control-click permits editing"); + table.getCellEditor().cancelCellEditing(); + allowEditor[0] = false; + } + } + check(fitter.isAutoFit(), "Autofit preserved"); + check(fitter.getUncertainty(0) == .05, "uncertainty preserved"); + check(fitter.fit.getParameterValue(0) == 2, "parameter value preserved"); + allowEditor[0] = true; + MouseEvent left = new MouseEvent(table, MouseEvent.MOUSE_PRESSED, 0, 0, 5, 5, 1, false, MouseEvent.BUTTON1); + check(table.editCellAt(0, 2, left), "left-click editing still available"); + table.getCellEditor().cancelCellEditing(); + if (args.length > 0) { + JFrame frame = new JFrame(); + frame.setContentPane(fitter); + frame.setSize(1000, 250); + frame.addNotify(); + frame.validate(); + java.awt.image.BufferedImage image = new java.awt.image.BufferedImage(1000, 250, java.awt.image.BufferedImage.TYPE_INT_RGB); + java.awt.Graphics2D graphics = image.createGraphics(); + frame.getContentPane().printAll(graphics); + graphics.dispose(); + try { javax.imageio.ImageIO.write(image, "png", new java.io.File(args[0])); } + catch (java.io.IOException ex) { throw new RuntimeException(ex); } + } + System.out.println("Passed: " + passed); + } finally { + for (Window window : Window.getWindows()) window.dispose(); + } + }); + System.exit(0); + } + static String labels(java.awt.Container parent) { + StringBuilder text = new StringBuilder(); + for (java.awt.Component child : parent.getComponents()) { + if (child instanceof JLabel) text.append(((JLabel)child).getText()).append("\n"); + if (child instanceof java.awt.Container) text.append(labels((java.awt.Container)child)); + } + return text.toString(); + } + static void reject(JTable table, int column, int button, int modifiers, boolean popup, String label) { + MouseEvent event = new MouseEvent(table, MouseEvent.MOUSE_PRESSED, 0, modifiers, 5, 5, 1, popup, button); + check(!table.editCellAt(0, column, event) && !table.isEditing(), label + " column " + column); + } + static void check(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + passed++; + System.out.println("PASS: " + message); + } +} diff --git a/test/org/opensourcephysics/tools/CurveFitPrecisionTest.java b/test/org/opensourcephysics/tools/CurveFitPrecisionTest.java new file mode 100644 index 00000000..b641c520 --- /dev/null +++ b/test/org/opensourcephysics/tools/CurveFitPrecisionTest.java @@ -0,0 +1,59 @@ +package org.opensourcephysics.tools; + +import java.util.Locale; +import org.opensourcephysics.display.OSPRuntime; + +/** Standalone regression checks for reporting precision and retained values. */ +public class CurveFitPrecisionTest { + private static int passed; + + public static void main(String[] args) { + Locale original = Locale.getDefault(); + char separator = OSPRuntime.getCurrentDecimalSeparator(); + try { + OSPRuntime.setPreferredDecimalSeparator("."); + display(1.23456789, 0.056789, "1.235 ± 0.057"); + display(1.23456789, 0.05001, "1.235 ± 0.050"); + display(-1.23456789, 0.056789, "-1.235 ± 0.057"); + display(0, 0.056789, "0.000 ± 0.057"); + display(1.23456789, 0.0996, "1.23 ± 0.10"); + display(1.23456789, 0.996, "1.2 ± 1.0"); + display(1.23456789, 9.96, "(0.1 ± 1.0) E1"); + display(12345.6789, 567.89, "(1.235 ± 0.057) E4"); + display(0.00123456789, 0.000056789, "(1.235 ± 0.057) E-3"); + display(0.12, 5.6789, "0.1 ± 5.7"); + double value = 1.2345678901234567; + double sigma = 0.0567890123456789; + String[] text = DatasetCurveFitter.formatParameterWithUncertainty(value, sigma, 1); + String[] tooltip = text[1].split(" ± "); + check(Double.doubleToLongBits(Double.parseDouble(tooltip[0])) == Double.doubleToLongBits(value), "tooltip preserves parameter double"); + check(Double.doubleToLongBits(Double.parseDouble(tooltip[1])) == Double.doubleToLongBits(sigma), "tooltip preserves uncertainty double"); + DatasetCurveFitter.NumberField field = new DatasetCurveFitter.NumberField(12); + field.applyPattern("0.###"); + field.setValue(value); + check(Double.doubleToLongBits(field.getValue()) == Double.doubleToLongBits(value), "unchanged rounded editor retains original double"); + for (double unavailable : new double[] {0, -1, Double.NaN, Double.POSITIVE_INFINITY}) { + check(DatasetCurveFitter.formatParameterWithUncertainty(value, unavailable, 1) == null, "unavailable uncertainty " + unavailable); + } + Locale.setDefault(Locale.GERMANY); + display(1.23456789, 0.056789, "1.235 ± 0.057"); + OSPRuntime.setPreferredDecimalSeparator(","); + display(1.23456789, 0.056789, "1,235 ± 0,057"); + System.out.println("Passed: " + passed); + } finally { + Locale.setDefault(original); + OSPRuntime.setPreferredDecimalSeparator(String.valueOf(separator)); + } + } + + private static void display(double value, double sigma, String expected) { + String actual = DatasetCurveFitter.formatParameterWithUncertainty(value, sigma, 1)[0]; + check(expected.equals(actual), "expected " + expected + "; got " + actual); + } + + private static void check(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + passed++; + System.out.println("PASS: " + message); + } +} diff --git a/test/org/opensourcephysics/tools/CurveFitReportTest.java b/test/org/opensourcephysics/tools/CurveFitReportTest.java new file mode 100644 index 00000000..9234431f --- /dev/null +++ b/test/org/opensourcephysics/tools/CurveFitReportTest.java @@ -0,0 +1,151 @@ +package org.opensourcephysics.tools; + +import org.opensourcephysics.display.Dataset; +import org.opensourcephysics.display.OSPRuntime; + +/** Analytic regression checks and tab-separated spreadsheet layout checks. */ +public class CurveFitReportTest { + static int passed; + public static void main(String[] args) throws Exception { + OSPRuntime.setPreferredDecimalSeparator("."); + KnownPolynomial line = new KnownPolynomial(new double[] {1, 2}); + Dataset data = new Dataset(); + data.setXYColumnNames("time", "position"); + // Residuals are orthogonal to the intercept and x: this is the exact OLS line. + data.append(new double[] {0, 1, 2, 3}, new double[] {1.1, 2.9, 4.9, 7.1}); + double[] stats = CurveFitReport.statistics(line, data, null, true); + close(stats[0], 4, "point count"); + close(stats[1], 2, "free parameters"); + close(stats[2], 2, "residual degrees of freedom"); + close(stats[3], .04, "SSE"); + close(stats[4], .1, "RMS uses n"); + close(stats[5], 1 - .04/20.04, "R-squared uses model residuals"); + close(stats[6], Math.sqrt(.02), "standard error uses n-p"); + close(stats[7], 20.04, "total sum of squares"); + String report = CurveFitReport.create(line, data, null, new double[] {.0632455532033676, .1183215956619923}, true, true); + String summary = CurveFitReport.createSummary(line, data, null, new double[] {.05, .01}, true, + "s", "m", FitUncertainty.estimated(), Double.NaN); + check(summary.contains("X variable\ttime (s)") && summary.contains("Y variable\tposition (m)"), "summary variable units"); + check(summary.contains("A\t2.0\t0.05\tm/s\tNo"), "summary full precision coefficients and units"); + check(!summary.contains("ANOVA") && !summary.contains("Chi square") && !summary.contains("Adjusted R"), "summary omits advanced analysis"); + check(summary.contains("Points\t4") && summary.contains("Degrees of freedom\t2"), "summary screen counts"); + close(cell(summary, "SSE", 1), stats[3], "summary SSE unchanged"); + close(cell(summary, "R-squared", 1), stats[5], "summary centered R squared unchanged"); + for (String row : summary.split("\n")) check(splitTabs(row).length == 5, "summary aligned spreadsheet columns"); + String manualSummary = CurveFitReport.createSummary(line, data, null, new double[] {.05, .01}, false, + null, null, FitUncertainty.estimated(), Double.NaN); + check(manualSummary.contains("A\t2.0\tN/A") && manualSummary.contains("Degrees of freedom\tN/A"), "summary manual behavior"); + String[] rows = report.split("\n"); + check(rows[0].startsWith("FIT\t"), "simple title and data-name fallback"); + check(report.contains("Equation\tposition = ") && rows[1].contains("Automatic fit"), "second header contains model, equation, and mode"); + for (String row : rows) check(splitTabs(row).length == 5, "five aligned spreadsheet columns"); + check(report.indexOf("GOODNESS OF FIT") < report.indexOf("ANOVA") + && report.indexOf("ANOVA") < report.indexOf("Parameter\tCoefficients"), "physics sections before supplementary ANOVA"); + close(cell(report, "Multiple R", 1), Math.sqrt(stats[5]), "Multiple R"); + close(cell(report, "Adjusted R Square (centered)", 1), 1 - (.04/2)/(20.04/3), "Adjusted R Square"); + close(cell(report, "Regression", 1), 1, "regression df"); + close(cell(report, "Regression", 2), 20, "regression SS"); + close(cell(report, "Regression", 3), 20, "regression MS"); + close(cell(report, "Regression", 4), 1000, "F statistic"); + close(cell(report, "Residual", 1), 2, "residual df"); + close(cell(report, "Residual", 2), .04, "residual SS"); + close(cell(report, "Residual", 3), .02, "residual MS"); + close(cell(report, "Total", 1), 3, "total df"); + check(report.split("Parameter\\tCoefficients", -1).length == 2, "coefficient header occurs once"); + check(!report.contains("rounded") && !report.contains("±"), "no repeated rounded coefficients"); + check(!report.toLowerCase().contains("weight"), "no irrelevant weights note"); + check(rows[rows.length - 1].startsWith("B\t"), "report ends with coefficients, without a prose footer"); + report = CurveFitReport.create(line, data, new boolean[] {true, false}, new double[] {.05, .01}, true, true); + close(cell(report, "Residual", 1), 3, "fixed parameter excluded from p"); + check(report.contains("A\t2.0\tN/A\t\tYes"), "fixed coefficient has no uncertainty"); + check(report.contains("Regression\tN/A\tN/A\tN/A\tN/A"), "no classical regression ANOVA for constrained fits"); + report = CurveFitReport.create(line, data, null, new double[] {.05, .01}, false, true); + check(report.contains("Manual parameters") && report.contains("A\t2.0\tN/A\t\tNo"), "manual mode does not reuse uncertainties"); + check(report.contains("Residual standard error\tN/A"), "manual mode has no fitted residual standard error"); + KnownPolynomial parabola = new KnownPolynomial(new double[] {0, 0, 1}); + Dataset curved = new Dataset(); + curved.append(new double[] {-2, -1, 0, 1, 2}, new double[] {4, 1, 0, 1, 4}); + close(CurveFitReport.statistics(parabola, curved, null, true)[5], 1, "nonlinear curve has R-squared 1 despite zero linear correlation"); + UserFunction nonlinear = new UserFunction("Exponential"); + nonlinear.setParameters(new String[] {"A"}, new double[] {1}, null); + nonlinear.setExpression("exp(A*x)", new String[] {"x"}); + report = CurveFitReport.create(nonlinear, data, null, null, true, true); + check(report.contains("Regression\tN/A\tN/A\tN/A\tN/A"), "no classical regression ANOVA for nonlinear parameter models"); + Dataset constant = new Dataset(); + constant.append(new double[] {0, 1, 2}, new double[] {3, 3, 3}); + check(Double.isNaN(CurveFitReport.statistics(line, constant, null, true)[5]), "constant response has undefined R-squared"); + Dataset shortData = new Dataset(); shortData.append(0, 1); + check(Double.isNaN(CurveFitReport.statistics(line, shortData, null, true)[6]), "insufficient degrees of freedom"); + check(Double.isNaN(CurveFitReport.statistics(line, null, null, true)[3]), "empty dataset statistics unavailable"); + KnownPolynomial poor = new KnownPolynomial(new double[] {100,100}); + check(CurveFitReport.statistics(poor, data, null, false)[5] < 0, "negative R-squared retained"); + UserFunction invalid = new UserFunction("Invalid"); invalid.setExpression("sqrt(-1)", new String[] {"x"}); + check(Double.isNaN(CurveFitReport.statistics(invalid, data, null, false)[3]), "undefined model statistics unavailable"); + UserFunction redundant = new UserFunction("Redundant"); + redundant.setParameters(new String[] {"A", "B"}, new double[] {1, 1}, null); + redundant.setExpression("(A+B)*x", new String[] {"x"}); + Dataset redundantData = new Dataset(); + redundantData.append(new double[] {0,1,2,3}, new double[] {.1,1.9,3.9,6.1}); + stats = CurveFitReport.statistics(redundant, redundantData, null, true); + close(stats[1], 2, "nominal free parameter count retained"); + close(stats[8], 1, "redundant parameters have rank one"); + close(stats[2], 3, "rank determines residual degrees of freedom"); + close(stats[6], Math.sqrt(.04/3), "rank-correct residual standard error"); + close(redundant.getParameterValue(0), 1, "rank calculation preserves original parameters"); + report = CurveFitReport.create(redundant, redundantData, null, null, true, true); + close(cell(report, "Residual", 1), 3, "export uses rank-correct df"); + close(cell(report, "Adjusted R Square (centered)", 1), stats[5], "adjusted R square uses effective rank"); + check(report.contains("Multiple R\tN/A"), "Multiple R withheld outside classical polynomial fits"); + UserFunction origin = new UserFunction("Origin"); + origin.setParameters(new String[] {"A"}, new double[] {17.0/7}, null); + origin.setExpression("A*x", new String[] {"x"}); + report = CurveFitReport.create(origin, data, null, null, true, true); + close(cell(report, "R Square (centered)", 1), .9267179925862561, "through-origin R square explicitly centered"); + stats = CurveFitReport.statistics(line, data, new boolean[] {true,true}, true); + close(stats[2], 4, "all-fixed model uses n residual degrees of freedom"); + Dataset repeated = new Dataset(); repeated.append(new double[] {1,1,1,1}, new double[] {1,2,3,4}); + close(CurveFitReport.statistics(line, repeated, null, true)[2], 3, "repeated x lowers polynomial rank"); + UserFunction scaled = new UserFunction("Scaled"); + scaled.setParameters(new String[] {"A","B"}, new double[] {1,1}, null); + scaled.setExpression("A*x+1e-9*B", new String[] {"x"}); + close(CurveFitReport.statistics(scaled, data, null, true)[8], 2, "rank tolerates different parameter units"); + KnownPolynomial large = new KnownPolynomial(new double[] {1e20, 1}); + close(CurveFitReport.statistics(large, data, null, true)[8], 2, "large coefficient does not hide polynomial rank"); + double value = 1.2345678901234567, sigma = .0567890123456789; + line.setParameterValue(0, value); + String params = CurveFitReport.create(line, data, null, new double[] {sigma,.01}, true, false); + String[] first = splitTabs(params.split("\n")[1]); + check(Double.doubleToLongBits(Double.parseDouble(first[1])) == Double.doubleToLongBits(value), "coefficient round-trips exactly"); + check(Double.doubleToLongBits(Double.parseDouble(first[2])) == Double.doubleToLongBits(sigma), "uncertainty round-trips exactly"); + check(params.split("\n").length == 3, "parameters-only export has one header and two rows"); + check(first[4].equals("No") && first[3].isEmpty(), "coefficient and uncertainty each exported once"); + check(line.getParameterValue(0) == value, "copying leaves model unchanged"); + OSPRuntime.setPreferredDecimalSeparator(","); + params = CurveFitReport.create(line, data, null, new double[] {sigma,.01}, true, false); + check(params.contains("1,2345678901234567\t0,0567890123456789"), "locale decimal separator with tab delimiter"); + OSPRuntime.setPreferredDecimalSeparator("."); + if (args.length > 0) java.nio.file.Files.write(java.nio.file.Paths.get(args[0]), + CurveFitReport.create(new KnownPolynomial(new double[] {1,2}), data, null, + new double[] {.0632455532033676,.1183215956619923}, true, true).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + System.out.println("Passed: " + passed); + } + static double cell(String report, String name, int column) { + for (String row : report.split("\n")) if (row.startsWith(name + "\t")) return Double.parseDouble(splitTabs(row)[column]); + throw new AssertionError("Missing row: " + name); + } + static void close(double actual, double expected, String message) { + check(Math.abs(actual - expected) < 1e-10 * Math.max(1,Math.abs(expected)), message + ": " + actual); + } + // Preserve trailing empty TSV cells even in SwingJS, whose String.split + // implementation does not support Java's negative-limit behavior. + static String[] splitTabs(String row) { + String[] cells = (row + " ").split("\t"); + int last = cells.length - 1; + cells[last] = cells[last].substring(0, cells[last].length() - 1); + return cells; + } + static void check(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + passed++; System.out.println("PASS: " + message); + } +}