Repository: biolab/orange3

Issue:
Hierarchical clustering does not show discrete variables in the annotations dropdown
**What's wrong?**
<!-- Be specific, clear, and concise. Include screenshots if relevant. -->
<!-- If you're getting an error message, copy it, and enclose it with three backticks (```). -->

Opening this issue for discussion. 

Hierarchical clustering does not show discrete variables in the annotations dropdown when discrete variables are in the features part of the table and distance metric ignore discrete variables (e.g. Cosine distance). The reason for that is that discrete variable is filtered out from the table in `row_items` of `DistMatrix`. I think it should still be present in the Annotations dropdown since otherwise, users are confused about where is the attribute.

**How can we reproduce the problem?** 
<!-- Upload a zip with the .ows file and data. -->
<!-- Describe the steps (open this widget, click there, then add this...) -->

1. Use the File widget and select the Iris dataset
2. Connect select columns and more the iris column to features
3. Connect the Distances widget and select cosine distance
4. Connect hierarchical clustering

<img width="875" alt="Screenshot 2021-09-08 at 12 49 05" src="https://user-images.githubusercontent.com/6421558/132497400-248889f5-269c-4942-aef5-bc2ee7fd05a4.png">

**What's your environment?**
- Operating system: Any
- Orange version: master - 3.30
- How you installed Orange: Doesn't matter



Discussion:
I see no reason why we should exclude them.
Yes, but I think it would be a good idea if Distances (re)moved discrete variables to metas in metrics in which they are ignored, as a signal to the user.

Resolving patch:
diff --git a/Orange/distance/base.py b/Orange/distance/base.py
--- a/Orange/distance/base.py
+++ b/Orange/distance/base.py
@@ -34,22 +34,29 @@ def _preprocess(table, impute=True):
 
 # TODO I have put this function here as a substitute the above `_preprocess`.
 # None of them really belongs here; (re?)move them, eventually.
-def remove_discrete_features(data):
+def remove_discrete_features(data, to_metas=False):
     """Remove discrete columns from the data."""
     new_domain = Domain(
         [a for a in data.domain.attributes if a.is_continuous],
         data.domain.class_vars,
-        data.domain.metas)
+        data.domain.metas
+        + (() if not to_metas
+           else tuple(a for a in data.domain.attributes if not a.is_continuous))
+    )
     return data.transform(new_domain)
 
 
-def remove_nonbinary_features(data):
+def remove_nonbinary_features(data, to_metas=False):
     """Remove non-binary columns from the data."""
     new_domain = Domain(
         [a for a in data.domain.attributes
          if a.is_discrete and len(a.values) == 2],
         data.domain.class_vars,
-        data.domain.metas)
+        data.domain.metas +
+        (() if not to_metas
+         else tuple(a for a in data.domain.attributes
+               if not (a.is_discrete and len(a.values) == 2))
+         if to_metas else ()))
     return data.transform(new_domain)
 
 def impute(data):
diff --git a/Orange/widgets/unsupervised/owdistances.py b/Orange/widgets/unsupervised/owdistances.py
--- a/Orange/widgets/unsupervised/owdistances.py
+++ b/Orange/widgets/unsupervised/owdistances.py
@@ -158,7 +158,7 @@ def _fix_discrete():
                     self.Error.no_continuous_features()
                     return False
                 self.Warning.ignoring_discrete()
-                data = distance.remove_discrete_features(data)
+                data = distance.remove_discrete_features(data, to_metas=True)
             return True
 
         def _fix_nonbinary():
@@ -171,7 +171,8 @@ def _fix_nonbinary():
                     return False
                 elif nbinary < len(data.domain.attributes):
                     self.Warning.ignoring_nonbinary()
-                    data = distance.remove_nonbinary_features(data)
+                    data = distance.remove_nonbinary_features(data,
+                                                              to_metas=True)
             return True
 
         def _fix_missing():


Test patch:
diff --git a/Orange/distance/tests/test_distance.py b/Orange/distance/tests/test_distance.py
--- a/Orange/distance/tests/test_distance.py
+++ b/Orange/distance/tests/test_distance.py
@@ -5,7 +5,8 @@
 import numpy as np
 from scipy.sparse import csr_matrix
 
-from Orange.data import ContinuousVariable, DiscreteVariable, Domain, Table
+from Orange.data import ContinuousVariable, DiscreteVariable, StringVariable,\
+    Domain, Table
 from Orange import distance
 
 
@@ -863,7 +864,6 @@ def test_jaccard_rows(self):
         model = distance.Jaccard().fit(self.data)
         assert_almost_equal(model.ps, np.array([0.5, 2/3, 0.75]))
 
-        # pylint: disable=bad-whitespace
         assert_almost_equal(
             model(self.data),
             1 - np.array([[      1,        2 / 2.5,        1 / 2.5,        2/3 / 3],
@@ -902,6 +902,7 @@ def test_zero_instances(self):
         dist_dense = self.Distance(dense_data)
         dist_sparse = self.Distance(sparse_data)
 
+        # false positive, pylint: disable=unsubscriptable-object
         self.assertEqual(dist_dense[0][1], 0)
         self.assertEqual(dist_sparse[0][1], 0)
         self.assertEqual(dist_dense[0][2], 1)
@@ -980,5 +981,42 @@ def test_interruptable_sqrt_scalar(self):
         np.testing.assert_array_equal(new_i, np.sqrt(i))
 
 
+class TestDataUtilities(unittest.TestCase):
+    def test_remove_discrete(self):
+        d1, d2, d3 = (DiscreteVariable(c, values=tuple("123")) for c in "abc")
+        c1, c2 = (ContinuousVariable(c) for c in "xy")
+        t = StringVariable("t")
+        domain = Domain([d1, c1], d2, [c2, d3, t])
+        data = Table.from_domain(domain, 5)
+
+        reduced = distance.remove_discrete_features(data)
+        self.assertEqual(reduced.domain.attributes, (c1, ))
+        self.assertEqual(reduced.domain.class_var, d2)
+        self.assertEqual(reduced.domain.metas, (c2, d3, t))
+
+        reduced = distance.remove_discrete_features(data, to_metas=True)
+        self.assertEqual(reduced.domain.attributes, (c1, ))
+        self.assertEqual(reduced.domain.class_var, d2)
+        self.assertEqual(reduced.domain.metas, (c2, d3, t, d1))
+
+    def test_remove_non_binary(self):
+        b1, b2, b3 = (DiscreteVariable(c, values=tuple("12")) for c in "abc")
+        d1, d2, d3 = (DiscreteVariable(c, values=tuple("123")) for c in "def")
+        c1, c2 = (ContinuousVariable(c) for c in "xy")
+        t = StringVariable("t")
+        domain = Domain([d1, b1, b2, c1], d2, [c2, d3, t, b3])
+        data = Table.from_domain(domain, 5)
+
+        reduced = distance.remove_nonbinary_features(data)
+        self.assertEqual(reduced.domain.attributes, (b1, b2))
+        self.assertEqual(reduced.domain.class_var, d2)
+        self.assertEqual(reduced.domain.metas, (c2, d3, t, b3))
+
+        reduced = distance.remove_nonbinary_features(data, to_metas=True)
+        self.assertEqual(reduced.domain.attributes, (b1, b2))
+        self.assertEqual(reduced.domain.class_var, d2)
+        self.assertEqual(reduced.domain.metas, (c2, d3, t, b3, d1, c1))
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/Orange/widgets/unsupervised/tests/test_owdistances.py b/Orange/widgets/unsupervised/tests/test_owdistances.py
--- a/Orange/widgets/unsupervised/tests/test_owdistances.py
+++ b/Orange/widgets/unsupervised/tests/test_owdistances.py
@@ -228,6 +228,36 @@ def assert_no_error():
         self.send_signal(widget.Inputs.data, self.iris)
         assert_no_error()
 
+    def test_discrete_in_metas(self):
+        domain = self.iris.domain
+        data = self.iris.transform(
+            Domain(domain.attributes[:-1] + (domain.class_var, ),
+                   [],
+                   domain.attributes[-1:])
+        )
+        for self.widget.metric_idx, (_, metric) in enumerate(METRICS):
+            if metric == distance.Cosine:
+                break
+        self.send_signal(self.widget.Inputs.data, data)
+        self.wait_until_finished()
+        out = self.get_output(self.widget.Outputs.distances)
+        out_domain = out.row_items.domain
+        self.assertEqual(out_domain.attributes, domain.attributes[:-1])
+        self.assertEqual(out_domain.metas,
+                         (domain.attributes[-1], domain.class_var))
+
+    def test_non_binary_in_metas(self):
+        for self.widget.metric_idx, (_, metric) in enumerate(METRICS):
+            if metric == distance.Jaccard:
+                break
+        zoo = Table("zoo")[:20]
+        self.send_signal(self.widget.Inputs.data, zoo)
+        self.wait_until_finished()
+        out = self.get_output(self.widget.Outputs.distances)
+        domain = zoo.domain
+        out_domain = out.row_items.domain
+        self.assertEqual(out_domain.metas, (domain["name"], domain["legs"]))
+
 
 if __name__ == "__main__":
     unittest.main()