diff --git a/app/save-and-restore/app/doc/images/compare-arrays-infinite-delta.png b/app/save-and-restore/app/doc/images/compare-arrays-infinite-delta.png new file mode 100644 index 0000000000..cc6ccf97fd Binary files /dev/null and b/app/save-and-restore/app/doc/images/compare-arrays-infinite-delta.png differ diff --git a/app/save-and-restore/app/doc/images/compare-arrays.png b/app/save-and-restore/app/doc/images/compare-arrays.png new file mode 100644 index 0000000000..f5a80fb9ec Binary files /dev/null and b/app/save-and-restore/app/doc/images/compare-arrays.png differ diff --git a/app/save-and-restore/app/doc/images/snapshot-view-with-delta.png b/app/save-and-restore/app/doc/images/snapshot-view-with-delta.png new file mode 100644 index 0000000000..f51479abcb Binary files /dev/null and b/app/save-and-restore/app/doc/images/snapshot-view-with-delta.png differ diff --git a/app/save-and-restore/app/doc/index.rst b/app/save-and-restore/app/doc/index.rst index f826a173fc..bdb3de6d1f 100644 --- a/app/save-and-restore/app/doc/index.rst +++ b/app/save-and-restore/app/doc/index.rst @@ -303,6 +303,29 @@ are shown by default. The left-most columns in the toolbar can be used to show/h .. image:: images/toggle-readback.png :width: 80% +While comparison of scalar values in the snapshot view is straight-forward, array (or table) type data is difficult +to compare from the single table cells. User may instead click on the highlighted ":math:`{\Delta}` Live" cell to launch a dialog +showing stored, live and :math:`{\Delta}` for the selected PV: + +.. image:: images/snapshot-view-with-delta.png + +User clicks "Click to compare": + +.. image:: images/compare-arrays.png + +The threshold settings works in the same manner is in the snapshot view and operates on each element (row) in the +table view. + +In case the stored and live value of the array/table data are of different dimensions, cells where no value is available +will be rendered as "---". Moreover, since in these cases an absolute delta cannot be computed, the delta column will also show +"---". + +User may click the table header of the delta column to sort on the delta value to quickly find rows where either the +stored or live value is not defined (due to difference in dimension). For such rows the absolute delta will be treated +as infinite, which impacts ordering on the delta column: + +.. image:: images/compare-arrays-infinite-delta.png + Restoring A Snapshot -------------------- diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java index db0047186a..38a8034493 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/Messages.java @@ -32,6 +32,7 @@ public class Messages { public static String buttonSearch; public static String cannotCompareHeader; public static String cannotCompareTitle; + public static String clickToCompare; public static String closeConfigurationWarning; public static String closeCompositeSnapshotWarning; public static String closeSnapshotWarning; @@ -39,6 +40,8 @@ public class Messages { public static String closeConfigurationTabPrompt; public static String closeSnapshotTabPrompt; public static String compositeSnapshotConsistencyCheckFailed; + public static String comparisonDialogLunchError; + public static String comparisonDialogTitle; public static String contextMenuAddTag; @Deprecated public static String contextMenuAddTagWithComment; @@ -107,6 +110,7 @@ public class Messages { public static String importSnapshotLabel; public static String includeThisPV; public static String inverseSelection; + public static String live; public static String liveReadbackVsSetpoint; public static String liveSetpoint; public static String login; @@ -159,6 +163,7 @@ public class Messages { public static String snapshotFromPvs; public static String status; public static String storedReadbackValue; + public static String stored; public static String storedValues; public static String tableColumnDeltaValue; public static String tagAddFailed; diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/VTypePair.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/VTypePair.java index 1d71acd33b..0942d11ef5 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/VTypePair.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/VTypePair.java @@ -17,8 +17,12 @@ */ package org.phoebus.applications.saveandrestore.ui; +import org.epics.vtype.VNumber; +import org.epics.vtype.VString; import org.epics.vtype.VType; +import org.phoebus.core.vtypes.VTypeHelper; import org.phoebus.saveandrestore.util.Threshold; +import org.phoebus.saveandrestore.util.VNoData; import java.util.Optional; @@ -35,7 +39,8 @@ public class VTypePair { public final Optional> threshold; /** - * Constructs a new pair. + * Constructs a new pair. In the context of save-and-restore snapshots, the {@link #value} field + * is used to hold a stored value, while {@link #base} holds the live PV value. * * @param base the base value * @param value the value that can be compared to base @@ -47,6 +52,39 @@ public VTypePair(VType base, VType value, Optional> threshold) { this.threshold = threshold; } + /** + * Computes absolute delta for the delta between {@link #base} and {@link #value}. When applied to + * {@link VString} types, {@link String#compareTo(String)} is used for comparison, but then converted to + * an absolute value. + * + *

+ * Main use case for this is ordering on delta. Absolute delta may be more useful as otherwise zero + * deltas would be found between positive and negative deltas. + *

+ *

+ * If {@link #base} or {@link #value} are null or {@link VNoData#INSTANCE}, then + * the delta cannot be computed as a number. In this case {@link Double#MAX_VALUE} is returned + * to indicate an "infinite delta". + *

+ * @return Absolute delta between {@link #base} and {@link #value}. + */ + public double getAbsoluteDelta(){ + if(base.equals(VNoData.INSTANCE) || + value.equals(VNoData.INSTANCE) || + base == null || + value == null){ + return Double.MAX_VALUE; + } + if(base instanceof VNumber){ + return Math.abs(((VNumber)base).getValue().doubleValue() - + ((VNumber)value).getValue().doubleValue()); + } + else if(base instanceof VString){ + return Math.abs(((VString)base).getValue().compareTo(((VString)value).getValue())); + } + else return 0.0; + } + /* * (non-Javadoc) * diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java index 6c7eaca1f6..0e5781836f 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/SnapshotController.java @@ -645,7 +645,7 @@ protected void updateItem(TableEntry item, boolean empty) { }); showDeltaPercentage.addListener((ob, o, n) -> deltaColumn.setCellFactory(e -> { - VDeltaCellEditor vDeltaCellEditor = new VDeltaCellEditor<>(); + VDeltaCellEditor vDeltaCellEditor = new VDeltaCellEditor<>(); vDeltaCellEditor.setShowDeltaPercentage(n); return vDeltaCellEditor; })); @@ -1453,7 +1453,7 @@ private void addSnapshot(Snapshot snapshot) { "", minWidth); deltaCol.setCellValueFactory(e -> e.getValue().compareValueProperty(additionalSnapshots.size())); deltaCol.setCellFactory(e -> { - VDeltaCellEditor vDeltaCellEditor = new VDeltaCellEditor<>(); + VDeltaCellEditor vDeltaCellEditor = new VDeltaCellEditor<>(); vDeltaCellEditor.setShowDeltaPercentage(showDeltaPercentage.get()); return vDeltaCellEditor; }); diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VDeltaCellEditor.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VDeltaCellEditor.java index a3a8d0ead3..faae91e738 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VDeltaCellEditor.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VDeltaCellEditor.java @@ -20,10 +20,16 @@ package org.phoebus.applications.saveandrestore.ui.snapshot; import javafx.scene.control.Tooltip; +import org.epics.vtype.VEnumArray; +import org.epics.vtype.VNumberArray; +import org.epics.vtype.VStringArray; +import org.phoebus.applications.saveandrestore.Messages; import org.phoebus.applications.saveandrestore.ui.VTypePair; +import org.phoebus.applications.saveandrestore.ui.snapshot.compare.ComparisonDialog; import org.phoebus.core.vtypes.VDisconnectedData; import org.phoebus.saveandrestore.util.Utilities; import org.phoebus.saveandrestore.util.VNoData; +import org.phoebus.ui.dialog.DialogHelper; import java.util.Formatter; @@ -34,7 +40,7 @@ * @param * @author Kunal Shroff */ -public class VDeltaCellEditor extends VTypeCellEditor { +public class VDeltaCellEditor extends VTypeCellEditor { private final Tooltip tooltip = new Tooltip(); @@ -44,7 +50,7 @@ protected void setShowDeltaPercentage(boolean showDeltaPercentage) { this.showDeltaPercentage = showDeltaPercentage; } - VDeltaCellEditor() { + public VDeltaCellEditor() { super(); } @@ -72,17 +78,36 @@ public void updateItem(T item, boolean empty) { setStyle(TableCellColors.DISCONNECTED_STYLE); } else if (pair.value == VNoData.INSTANCE) { setText(pair.value.toString()); - } else { + } else if(pair.base == VNoData.INSTANCE){ + setText(VNoData.INSTANCE.toString()); + } + else { Utilities.VTypeComparison vtc = Utilities.deltaValueToString(pair.value, pair.base, pair.threshold); - String percentage = Utilities.deltaValueToPercentage(pair.value, pair.base); - if (!percentage.isEmpty() && showDeltaPercentage) { - Formatter formatter = new Formatter(); - setText(formatter.format("%g", Double.parseDouble(vtc.getString())) + " (" + percentage + ")"); - } else { - setText(vtc.getString()); - } - if (!vtc.isWithinThreshold()) { + if (vtc.getValuesEqual() != 0 && + (pair.base instanceof VNumberArray || + pair.base instanceof VStringArray || + pair.base instanceof VEnumArray)) { + TableEntry tableEntry = (TableEntry) getTableRow().getItem(); + setText(Messages.clickToCompare); setStyle(TableCellColors.ALARM_MAJOR_STYLE); + setOnMouseClicked(e -> { + ComparisonDialog comparisonDialog = new ComparisonDialog(tableEntry.getSnapshotVal().get(), tableEntry.getConfigPv().getPvName()); + DialogHelper.positionDialog(comparisonDialog, getTableView(), -400, -400); + comparisonDialog.show(); + }); + } else { + // Do not handle mouse clicked, e.g. if live PV is disconnected. + setOnMouseClicked(e -> {}); + String percentage = Utilities.deltaValueToPercentage(pair.value, pair.base); + if (!percentage.isEmpty() && showDeltaPercentage) { + Formatter formatter = new Formatter(); + setText(formatter.format("%g", Double.parseDouble(vtc.getString())) + " (" + percentage + ")"); + } else { + setText(vtc.getString()); + } + if (!vtc.isWithinThreshold()) { + setStyle(TableCellColors.ALARM_MAJOR_STYLE); + } } } tooltip.setText(item.toString()); diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VTypeCellEditor.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VTypeCellEditor.java index 032676186e..ff10c3a32e 100644 --- a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VTypeCellEditor.java +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/VTypeCellEditor.java @@ -49,10 +49,10 @@ * @param {@link org.epics.vtype.VType} or {@link org.phoebus.applications.saveandrestore.ui.VTypePair} * @author Jaka Bobnar */ -public class VTypeCellEditor extends MultitypeTableCell { +public class VTypeCellEditor extends MultitypeTableCell { private final Tooltip tooltip = new Tooltip(); - VTypeCellEditor() { + public VTypeCellEditor() { setConverter(new StringConverter<>() { @Override diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ColumnEntry.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ColumnEntry.java new file mode 100644 index 0000000000..6246262ab3 --- /dev/null +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ColumnEntry.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.ui.snapshot.compare; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import org.epics.vtype.VNumber; +import org.epics.vtype.VType; +import org.phoebus.applications.saveandrestore.SafeMultiply; +import org.phoebus.applications.saveandrestore.ui.VTypePair; +import org.phoebus.saveandrestore.util.Threshold; +import org.phoebus.saveandrestore.util.Utilities; +import org.phoebus.saveandrestore.util.VNoData; + +import java.util.Optional; + +/** + * Data class for one column in the comparison table. + */ +public class ColumnEntry { + + /** + * The {@link VType} value as stored in a {@link org.phoebus.applications.saveandrestore.model.Snapshot} + */ + private final ObjectProperty storedValue = new SimpleObjectProperty<>(this, "storedValue", null); + /** + * A {@link VTypePair} property holding data for the purpose of calculating and showing a delta. + */ + private final ObjectProperty delta = new SimpleObjectProperty<>(this, "delta", null); + /** + * The live {@link VType} value as read from a connected PV. + */ + private final ObjectProperty liveValue = new SimpleObjectProperty<>(this, "liveValue", VNoData.INSTANCE); + + private Optional> threshold = Optional.empty(); + + public ColumnEntry(VType storedValue) { + this.storedValue.set(storedValue); + } + + public ObjectProperty storedValueProperty() { + return storedValue; + } + + public void setLiveVal(VType liveValue) { + this.liveValue.set(liveValue); + VTypePair vTypePair = new VTypePair(liveValue, storedValue.get(), threshold); + delta.set(vTypePair); + } + + public ObjectProperty liveValueProperty() { + return liveValue; + } + + public ObjectProperty getDelta() { + return delta; + } + + /** + * Set the threshold value for this entry. All value comparisons related to this entry are calculated using the + * threshold (if it exists). + * + * @param ratio the threshold + */ + public void setThreshold(double ratio) { + if (storedValue.get() instanceof VNumber) { + VNumber vNumber = SafeMultiply.multiply((VNumber) storedValue.get(), ratio); + boolean isNegative = vNumber.getValue().doubleValue() < 0; + Threshold t = new Threshold<>(isNegative ? SafeMultiply.multiply(vNumber.getValue(), -1.0) : vNumber.getValue()); + this.delta.set(new VTypePair(liveValue.get(), storedValue.get(), Optional.of(t))); + } + } +} diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonData.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonData.java new file mode 100644 index 0000000000..7026add0d2 --- /dev/null +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonData.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.ui.snapshot.compare; + +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import org.epics.vtype.VType; +import org.phoebus.applications.saveandrestore.ui.VTypePair; +import org.phoebus.saveandrestore.util.Threshold; +import org.phoebus.saveandrestore.util.Utilities; + +import java.util.List; +import java.util.Optional; + +/** + * Data class for the {@link javafx.scene.control.TableView} of the comparison dialog. + */ +public class ComparisonData { + + /** + * Index (=row number) for this instance. + */ + private final IntegerProperty index = new SimpleIntegerProperty(this, "index"); + /** + * {@link List} of {@link ColumnEntry}s, one for each column in the data. For array data this will + * hold only one element. + */ + private final List columnEntries; + + public ComparisonData(int index, List columnEntries) { + this.index.set(index); + this.columnEntries = columnEntries; + } + + @SuppressWarnings("unused") + public IntegerProperty indexProperty() { + return index; + } + + public List getColumnEntries() { + return columnEntries; + } + + /** + * Set the threshold value for this entry. All value comparisons related to this entry are calculated using the + * threshold (if it exists). + * + * @param ratio the threshold + */ + public void setThreshold(double ratio) { + columnEntries.forEach(columnEntry -> columnEntry.setThreshold(ratio)); + } +} diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialog.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialog.java new file mode 100644 index 0000000000..9f531b8e4f --- /dev/null +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialog.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.ui.snapshot.compare; + +import javafx.fxml.FXMLLoader; +import javafx.scene.Node; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Dialog; +import org.epics.vtype.VType; +import org.phoebus.applications.saveandrestore.Messages; +import org.phoebus.framework.nls.NLS; + +import java.io.IOException; +import java.util.ResourceBundle; + +/** + * Dialog showing a {@link javafx.scene.control.TableView} where array or table data is visualized element wise. + * Purpose is to be able to inspect deltas on array/table element level. + * + *

+ * Data in the {@link javafx.scene.control.TableView} is organized column wise. Each row in the {@link javafx.scene.control.TableView} + * corresponds to an individual element in the data. Each column contains three nested columns: stored value, + * delta and live value. + *

+ *

+ * For an array type ({@link org.epics.vtype.VNumberArray} the table will thus hold a single data column. For a + * table type ({@link org.epics.vtype.VTable} there will be one data column for each column in the table. + *

+ */ +public class ComparisonDialog extends Dialog { + + /** + * Constructor + * @param data The data as stored in a {@link org.phoebus.applications.saveandrestore.model.Snapshot} + * @param pvName The name of the for which + */ + public ComparisonDialog(VType data, String pvName){ + + getDialogPane().getButtonTypes().addAll(ButtonType.CLOSE); + setResizable(true); + setTitle(Messages.comparisonDialogTitle); + + ResourceBundle resourceBundle = NLS.getMessages(Messages.class); + FXMLLoader loader = new FXMLLoader(); + loader.setResources(resourceBundle); + loader.setLocation(this.getClass().getResource("TableComparisonView.fxml")); + try { + Node node = loader.load(); + TableComparisonViewController controller = loader.getController(); + controller.loadDataAndConnect(data, pvName); + getDialogPane().setContent(node); + setOnCloseRequest(e -> controller.cleanUp()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonViewController.java b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonViewController.java new file mode 100644 index 0000000000..ce6f296cc7 --- /dev/null +++ b/app/save-and-restore/app/src/main/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonViewController.java @@ -0,0 +1,406 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.ui.snapshot.compare; + + +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.fxml.FXML; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.Tooltip; +import javafx.util.converter.DoubleStringConverter; +import org.epics.util.array.ListBoolean; +import org.epics.vtype.VBoolean; +import org.epics.vtype.VBooleanArray; +import org.epics.vtype.VByte; +import org.epics.vtype.VByteArray; +import org.epics.vtype.VDouble; +import org.epics.vtype.VDoubleArray; +import org.epics.vtype.VEnumArray; +import org.epics.vtype.VFloat; +import org.epics.vtype.VFloatArray; +import org.epics.vtype.VInt; +import org.epics.vtype.VIntArray; +import org.epics.vtype.VLong; +import org.epics.vtype.VLongArray; +import org.epics.vtype.VNumberArray; +import org.epics.vtype.VShort; +import org.epics.vtype.VShortArray; +import org.epics.vtype.VString; +import org.epics.vtype.VStringArray; +import org.epics.vtype.VType; +import org.epics.vtype.VUByte; +import org.epics.vtype.VUByteArray; +import org.epics.vtype.VUInt; +import org.epics.vtype.VUIntArray; +import org.epics.vtype.VULong; +import org.epics.vtype.VULongArray; +import org.epics.vtype.VUShort; +import org.epics.vtype.VUShortArray; +import org.phoebus.applications.saveandrestore.Messages; +import org.phoebus.applications.saveandrestore.ui.VTypePair; +import org.phoebus.applications.saveandrestore.ui.snapshot.VDeltaCellEditor; +import org.phoebus.applications.saveandrestore.ui.snapshot.VTypeCellEditor; +import org.phoebus.core.vtypes.VDisconnectedData; +import org.phoebus.core.vtypes.VTypeHelper; +import org.phoebus.pv.PV; +import org.phoebus.pv.PVPool; +import org.phoebus.saveandrestore.util.Utilities; +import org.phoebus.saveandrestore.util.VNoData; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Controller class for the comparison table view. + */ +public class TableComparisonViewController { + + @SuppressWarnings("unused") + @FXML + private TableView comparisonTable; + + @SuppressWarnings("unused") + @FXML + private TableColumn indexColumn; + + @SuppressWarnings("unused") + @FXML + private TableColumn storedValueColumn; + + @SuppressWarnings("unused") + @FXML + private TableColumn liveValueColumn; + + @SuppressWarnings("unused") + @FXML + private TableColumn deltaColumn; + + @SuppressWarnings("unused") + @FXML + private Spinner thresholdSpinner; + + @SuppressWarnings("unused") + @FXML + private Label pvName; + + @SuppressWarnings("unused") + @FXML + private Label dimensionStored; + + @SuppressWarnings("unused") + @FXML + private Label dimensionLive; + + @SuppressWarnings("unused") + @FXML + private Label nonEqualCount; + + + private final StringProperty pvNameProperty = new SimpleStringProperty(); + private final StringProperty dimensionStoredProperty = new SimpleStringProperty(); + private final StringProperty dimensionLiveProperty = new SimpleStringProperty(); + private final StringProperty nonEqualCountProperty = new SimpleStringProperty("0"); + + private PV pv; + + /** + * The time between updates of dynamic data in the table, in ms. + */ + private static final long TABLE_UPDATE_INTERVAL = 500; + + @FXML + public void initialize() { + comparisonTable.getStylesheets().add(TableComparisonViewController.class.getResource("/save-and-restore-style.css").toExternalForm()); + pvName.textProperty().bind(pvNameProperty); + storedValueColumn.setCellValueFactory(cell -> + cell.getValue().getColumnEntries().get(0).storedValueProperty()); + storedValueColumn.setCellFactory(e -> new VTypeCellEditor<>()); + liveValueColumn.setCellValueFactory(cell -> + cell.getValue().getColumnEntries().get(0).liveValueProperty()); + liveValueColumn.setCellFactory(e -> new VTypeCellEditor<>()); + deltaColumn.setCellValueFactory(cell -> + cell.getValue().getColumnEntries().get(0).getDelta()); + deltaColumn.setComparator(Comparator.comparingDouble(VTypePair::getAbsoluteDelta)); + + deltaColumn.setCellFactory(e -> new VDeltaCellEditor<>()); + + SpinnerValueFactory thresholdSpinnerValueFactory = new SpinnerValueFactory.DoubleSpinnerValueFactory(0.0, 999.0, 0.0, 0.01); + thresholdSpinnerValueFactory.setConverter(new DoubleStringConverter()); + thresholdSpinner.setValueFactory(thresholdSpinnerValueFactory); + thresholdSpinner.getEditor().setAlignment(Pos.CENTER_RIGHT); + thresholdSpinner.getEditor().textProperty().addListener((a, o, n) -> parseAndUpdateThreshold(n)); + + dimensionLive.textProperty().bind(dimensionLiveProperty); + dimensionStored.textProperty().bind(dimensionStoredProperty); + nonEqualCount.textProperty().bind(nonEqualCountProperty); + } + + /** + * Loads snapshot data and then connects to the corresponding PV. + * + * @param data Data as stored in a {@link org.phoebus.applications.saveandrestore.model.Snapshot} + * @param pvName The name of the PV. + */ + public void loadDataAndConnect(VType data, String pvName) { + + pvNameProperty.set(pvName); + + int arraySize = VTypeHelper.getArraySize(data); + for (int index = 0; index < arraySize; index++) { + List columnEntries = new ArrayList<>(); + ColumnEntry columnEntry = null; + if (data instanceof VNumberArray) { + if (data instanceof VDoubleArray array) { + double value = array.getData().getDouble(index); + columnEntry = new ColumnEntry(VDouble.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (data instanceof VFloatArray array) { + float value = array.getData().getFloat(index); + columnEntry = new ColumnEntry(VFloat.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (data instanceof VIntArray array) { + int value = array.getData().getInt(index); + columnEntry = new ColumnEntry(VInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (data instanceof VUIntArray array) { + int value = array.getData().getInt(index); + columnEntry = new ColumnEntry(VUInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (data instanceof VLongArray array) { + long value = array.getData().getLong(index); + columnEntry = new ColumnEntry(VLong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (data instanceof VULongArray array) { + long value = array.getData().getLong(index); + columnEntry = new ColumnEntry(VULong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (data instanceof VShortArray array) { + short value = array.getData().getShort(index); + columnEntry = new ColumnEntry(VShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (data instanceof VUShortArray array) { + short value = array.getData().getShort(index); + columnEntry = new ColumnEntry(VUShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (data instanceof VByteArray array) { + byte value = array.getData().getByte(index); + columnEntry = new ColumnEntry(VByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (data instanceof VUByteArray array) { + byte value = array.getData().getByte(index); + columnEntry = new ColumnEntry(VUByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } + } + else if (data instanceof VBooleanArray array) { + ListBoolean listBoolean = array.getData(); + boolean value = listBoolean.getBoolean(index); + columnEntry = new ColumnEntry(VBoolean.of(value, array.getAlarm(), array.getTime())); + } else if (data instanceof VEnumArray array) { + List enumValues = array.getData(); + columnEntry = new ColumnEntry(VString.of(enumValues.get(index), array.getAlarm(), array.getTime())); + } else if (data instanceof VStringArray array) { + List stringValues = array.getData(); + columnEntry = new ColumnEntry(VString.of(stringValues.get(index), array.getAlarm(), array.getTime())); + } + if(columnEntry != null){ + addRow(index, columnEntries, columnEntry); + } + } + + // Hard coded column count until we support VTable + dimensionStoredProperty.set(arraySize + " x 1"); + connect(); + } + + private void addRow(int index, List columnEntries, ColumnEntry columnEntry) { + columnEntries.add(columnEntry); + ComparisonData comparisonData = new ComparisonData(index, columnEntries); + comparisonTable.getItems().add(index, comparisonData); + } + + /** + * Attempts to connect to the PV. + */ + private void connect() { + try { + pv = PVPool.getPV(pvNameProperty.get()); + pv.onValueEvent().throttleLatest(TABLE_UPDATE_INTERVAL, TimeUnit.MILLISECONDS) + .subscribe(value -> updateTable(PV.isDisconnected(value) ? VDisconnectedData.INSTANCE : value)); + } catch (Exception e) { + Logger.getLogger(TableComparisonViewController.class.getName()).log(Level.INFO, "Error connecting to PV", e); + } + } + + /** + * Returns PV to pool, e.g. when UI is dismissed. + */ + public void cleanUp() { + if (pv != null) { + PVPool.releasePV(pv); + } + } + + /** + * Updates the {@link TableView} from the live data acquired through a PV monitor event. + * Differences in data sizes between stored and live data is considered. + * + * @param liveData EPICS data from the connected PV, or {@link VDisconnectedData#INSTANCE}. + */ + private void updateTable(VType liveData) { + if (liveData.equals(VDisconnectedData.INSTANCE)) { + comparisonTable.getItems().forEach(i -> i.getColumnEntries().get(0).setLiveVal(VDisconnectedData.INSTANCE)); + } else { + int liveDataArraySize = VTypeHelper.getArraySize(liveData); + comparisonTable.getItems().forEach(i -> { + int index = i.indexProperty().get(); + ColumnEntry columnEntry = i.getColumnEntries().get(0); + if (liveData instanceof VNumberArray) { + if (index >= liveDataArraySize) { // Live data has fewer elements than stored data + columnEntry.setLiveVal(VNoData.INSTANCE); + } else if (liveData instanceof VDoubleArray array) { + columnEntry.setLiveVal(VDouble.of(array.getData().getDouble(index), array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VShortArray array) { + columnEntry.setLiveVal(VShort.of(array.getData().getShort(index), array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VIntArray array) { + columnEntry.setLiveVal(VInt.of(array.getData().getInt(index), array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VUIntArray array) { + columnEntry.setLiveVal(VUInt.of(array.getData().getInt(index), array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VLongArray array) { + columnEntry.setLiveVal(VLong.of(array.getData().getLong(index), array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VULongArray array) { + columnEntry.setLiveVal(VULong.of(array.getData().getLong(index), array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VFloatArray array) { + columnEntry.setLiveVal(VFloat.of(array.getData().getFloat(index), array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VShortArray array) { + columnEntry.setLiveVal(VUShort.of(array.getData().getShort(index), array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VUShortArray array) { + columnEntry.setLiveVal(VShort.of(array.getData().getShort(index), array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VByteArray array) { + columnEntry.setLiveVal(VByte.of(array.getData().getShort(index), array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VUByteArray array) { + columnEntry.setLiveVal(VUByte.of(array.getData().getShort(index), array.getAlarm(), array.getTime(), array.getDisplay())); + } + } else if (liveData instanceof VBooleanArray array) { + if (index >= array.getData().size()) { // Live data has fewer elements than stored data + columnEntry.setLiveVal(VNoData.INSTANCE); + } else { + columnEntry.setLiveVal(VBoolean.of(array.getData().getBoolean(index), array.getAlarm(), array.getTime())); + } + } else if (liveData instanceof VEnumArray array) { + if (index >= array.getData().size()) { // Live data has fewer elements than stored data + columnEntry.setLiveVal(VNoData.INSTANCE); + } else { + columnEntry.setLiveVal(VString.of(array.getData().get(index), array.getAlarm(), array.getTime())); + } + } else if (liveData instanceof VStringArray array) { + if (index >= array.getData().size()) { // Live data has fewer elements than stored data + columnEntry.setLiveVal(VNoData.INSTANCE); + } else { + columnEntry.setLiveVal(VString.of(array.getData().get(index), array.getAlarm(), array.getTime())); + } + } + }); + // Live data may have more elements than stored data + if (liveDataArraySize > comparisonTable.getItems().size()) { + List columnEntries = new ArrayList<>(); + for (int index = comparisonTable.getItems().size(); index < liveDataArraySize; index++) { + ColumnEntry columnEntry = new ColumnEntry(VNoData.INSTANCE); + if (liveData instanceof VNumberArray) { + if (liveData instanceof VDoubleArray array) { + double value = array.getData().getDouble(index); + columnEntry.setLiveVal(VDouble.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VFloatArray array) { + float value = array.getData().getFloat(index); + columnEntry.setLiveVal(VFloat.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VIntArray array) { + int value = array.getData().getInt(index); + columnEntry.setLiveVal(VInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VUIntArray array) { + int value = array.getData().getInt(index); + columnEntry.setLiveVal(VUInt.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VLongArray array) { + long value = array.getData().getLong(index); + columnEntry.setLiveVal(VLong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VULongArray array) { + long value = array.getData().getLong(index); + columnEntry.setLiveVal(VULong.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VShortArray array) { + short value = array.getData().getShort(index); + columnEntry.setLiveVal(VShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VUShortArray array) { + short value = array.getData().getShort(index); + columnEntry.setLiveVal(VUShort.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VByteArray array) { + byte value = array.getData().getByte(index); + columnEntry.setLiveVal(VByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } else if (liveData instanceof VUByteArray array) { + byte value = array.getData().getByte(index); + columnEntry.setLiveVal(VUByte.of(value, array.getAlarm(), array.getTime(), array.getDisplay())); + } + } + else if (liveData instanceof VBooleanArray array) { + ListBoolean listBoolean = array.getData(); + boolean value = listBoolean.getBoolean(index); + columnEntry.setLiveVal(VBoolean.of(value, array.getAlarm(), array.getTime())); + } else if (liveData instanceof VEnumArray array) { + List enumValues = array.getData(); + columnEntry.setLiveVal(VString.of(enumValues.get(index), array.getAlarm(), array.getTime())); + } else if (liveData instanceof VStringArray array) { + List stringValues = array.getData(); + columnEntry.setLiveVal(VString.of(stringValues.get(index), array.getAlarm(), array.getTime())); + } + addRow(index, columnEntries, columnEntry); + } + } + // Hard coded column count until we support VTable + dimensionLiveProperty.set(liveDataArraySize + " x 1"); + computeNonEqualCount(); + } + + } + + private void parseAndUpdateThreshold(String value) { + thresholdSpinner.getEditor().getStyleClass().remove("input-error"); + thresholdSpinner.setTooltip(null); + try { + double parsedNumber = Double.parseDouble(value.trim()); + updateThreshold(parsedNumber); + } catch (Exception e) { + thresholdSpinner.getEditor().getStyleClass().add("input-error"); + thresholdSpinner.setTooltip(new Tooltip(Messages.toolTipMultiplierSpinner)); + } + } + + /** + * Computes thresholds on the individual elements. The threshold is used to indicate that a delta value within threshold + * should not decorate the delta column. + * + * @param threshold Threshold in percent + */ + private void updateThreshold(double threshold) { + double ratio = threshold / 100; + + comparisonTable.getItems().forEach(comparisonData -> { + comparisonData.setThreshold(ratio); + }); + + computeNonEqualCount(); + } + + private void computeNonEqualCount(){ + AtomicInteger nonEqualCount = new AtomicInteger(0); + comparisonTable.getItems().forEach(comparisonData -> { + comparisonData.getColumnEntries().forEach(columnEntry -> { + if(!Utilities.areValuesEqual(columnEntry.liveValueProperty().get(), columnEntry.storedValueProperty().get(), columnEntry.getDelta().get().threshold)){ + nonEqualCount.incrementAndGet(); + } + }); + }); + + nonEqualCountProperty.set(nonEqualCount.toString()); + } +} diff --git a/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties index 7371d54ca7..12f05b08bb 100644 --- a/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties +++ b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/messages.properties @@ -15,6 +15,7 @@ cancel=Cancel cannotCompareHeader=No snapshot data available for comparison. cannotCompareTitle=Cannot Compare choose=Choose +clickToCompare=Click to compare closeConfigurationWarning=Save&restore configuration modified, but not saved. Do you wish to continue? closeCompositeSnapshotWarning=Composite snapshot modified, but not saved. Do you wish to continue? closeSnapshotWarning=Save&restore snapshot created or modified, but not saved. Do you wish to continue? @@ -29,6 +30,8 @@ copy=Copy createdDate=Created createLogEntry=Create Log Entry createLogEntryToolTip=Create a log entry when snapshot has been saved or restored +comparisonDialogLunchError=Failed to create comparison dialog +comparisonDialogTitle=Comparing array/table data compositeSnapshotName=Composite Snapshot Name configurationLocation=Location configurationName=Configuration Name @@ -71,6 +74,8 @@ deleteFilter=Delete Filter deleteFilterFailed=Failed to delete filter description=Description descriptionHint=Specify non-empty description +dimensionLive=Dimension Live +dimensionStored=Dimension Stored duplicatePVNamesAdditionalItems={0} additional PV names duplicatePVNamesCheckFailed=Cannot check if snapshots may be added, remote service off-line? duplicatePVNamesFoundInSelection=Cannot add selected snapshots, duplicate PV names found @@ -124,6 +129,7 @@ jmasarServiceUnavailable=Save-and-restore service unavailable! labelMultiplier=Restore with scale labelThreshold=\u0394 Threshold (%) lastModifiedDate=Last Modified +live=Live liveReadbackVsSetpoint=Live Readback\n(? Live Setpoint) liveSetpoint=Live Setpoint logAction=Create Log Entry @@ -141,6 +147,7 @@ noValueAvailable=No Value Available help=Help hitsPerPage=Hits per page nodeSelectionForConfiguration=Select Node +nonEqualCount=Non-equal count openResourceFailedTitle=Unable To Open openResourceFailedHeader=Node with id "{0}" does not exist openSearchView=Open Search View @@ -216,6 +223,7 @@ snapshotLocation=Location snapshotName=Name snapshotNameFieldHint=Enter a name (case-sensitive) status=Status +stored=Stored storedReadbackValue=Stored Readback Value storedValues=Stored Setpoints tableColumnAlarmSeverity=Alarm Severity diff --git a/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonView.fxml b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonView.fxml new file mode 100644 index 0000000000..b0d0e9cb9b --- /dev/null +++ b/app/save-and-restore/app/src/main/resources/org/phoebus/applications/saveandrestore/ui/snapshot/compare/TableComparisonView.fxml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+
+ + + + + + + + + + + + + + + + +
+ +
diff --git a/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java new file mode 100644 index 0000000000..2781bd3695 --- /dev/null +++ b/app/save-and-restore/app/src/test/java/org/phoebus/applications/saveandrestore/ui/snapshot/compare/ComparisonDialogDemo.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2025 European Spallation Source ERIC. + */ + +package org.phoebus.applications.saveandrestore.ui.snapshot.compare; + +import javafx.stage.Stage; +import org.epics.util.array.ArrayDouble; +import org.epics.vtype.Alarm; +import org.epics.vtype.Display; +import org.epics.vtype.Time; +import org.epics.vtype.VDoubleArray; +import org.phoebus.ui.javafx.ApplicationWrapper; + +/** + * Utility class for the purpose of testing the {@link ComparisonDialog}. It uses + * a local array data source for comparison to a hard coded {@link VDoubleArray}. + */ +public class ComparisonDialogDemo extends ApplicationWrapper { + + public static void main(String[] args) { + launch(ComparisonDialogDemo.class, args); + } + + @Override + public void start(Stage primaryStage) { + + VDoubleArray vDoubleArray = + VDoubleArray.of(ArrayDouble.of(1, 2, 3, 4), + Alarm.none(), + Time.now(), Display.none()); + ComparisonDialog comparisonDialog = + new ComparisonDialog(vDoubleArray, "loc://x(3, 2, 1)"); + comparisonDialog.show(); + } +} diff --git a/app/save-and-restore/util/src/main/java/org/phoebus/saveandrestore/util/Utilities.java b/app/save-and-restore/util/src/main/java/org/phoebus/saveandrestore/util/Utilities.java index 9d6283cc69..8329b7c09a 100644 --- a/app/save-and-restore/util/src/main/java/org/phoebus/saveandrestore/util/Utilities.java +++ b/app/save-and-restore/util/src/main/java/org/phoebus/saveandrestore/util/Utilities.java @@ -97,13 +97,13 @@ public static class VTypeComparison { private final boolean withinThreshold; private double absoluteDelta = 0.0; - VTypeComparison(String string, int equal, boolean withinThreshold) { + public VTypeComparison(String string, int equal, boolean withinThreshold) { this.string = string; this.valuesEqual = equal; this.withinThreshold = withinThreshold; } - VTypeComparison(String string, int equal, boolean withinThreshold, double absoluteDelta) { + public VTypeComparison(String string, int equal, boolean withinThreshold, double absoluteDelta) { this.string = string; this.valuesEqual = equal; this.withinThreshold = withinThreshold; diff --git a/core/vtype/src/main/java/org/phoebus/core/vtypes/VTypeHelper.java b/core/vtype/src/main/java/org/phoebus/core/vtypes/VTypeHelper.java index 843db11ae8..6011f7bc21 100644 --- a/core/vtype/src/main/java/org/phoebus/core/vtypes/VTypeHelper.java +++ b/core/vtype/src/main/java/org/phoebus/core/vtypes/VTypeHelper.java @@ -278,6 +278,8 @@ public static int getArraySize(final VType value) { sizes = ((VEnumArray) value).getSizes(); } else if (value instanceof VStringArray) { sizes = ((VStringArray) value).getSizes(); + } else if (value instanceof VBooleanArray) { + sizes = ((VBooleanArray) value).getSizes(); } else { return 0; } diff --git a/core/vtype/src/test/java/org/phoebus/core/vtypes/VTypeHelperTest.java b/core/vtype/src/test/java/org/phoebus/core/vtypes/VTypeHelperTest.java index 6c7f6d1393..360034e50e 100644 --- a/core/vtype/src/test/java/org/phoebus/core/vtypes/VTypeHelperTest.java +++ b/core/vtype/src/test/java/org/phoebus/core/vtypes/VTypeHelperTest.java @@ -192,6 +192,10 @@ public void testGetArraySize() { VStringArray stringArray = VStringArray.of(Arrays.asList("a", "b"), alarm, time); assertEquals(2, VTypeHelper.getArraySize(stringArray)); + + VBooleanArray booleanArray = + VBooleanArray.of(ArrayBoolean.of(true, false, true), alarm, time); + assertEquals(3, VTypeHelper.getArraySize(booleanArray)); } @Test