Repository: cocotb/cocotb

Issue:
Readback of a signal with length 0 (VHDL2008)
When I read back a signal of length 0, the conversion fails. 
I attached a minimal example. If the cntrlwidth is set to >0 it works. Signals with length 0 can for example be assigned to unused signals in buses. I expected the conversion to return 0.

```
Traceback (most recent call last):
#            File "binaryvalue_bug/binaryvaluetest.py", line 49, in test_cntrl
#            assert dut.Cntrl_in.value.integer == dut.Cntrl_out.value.integer
#            File "cocotb/cocotb/binary.py", line 294, in integer
#            return self._convert_from[self.binaryRepresentation](self._str)
#            File "cocotb/cocotb/binary.py", line 200, in _convert_from_unsigned
#            return int(resolve(x), 2)
#            ValueError: invalid literal for int() with base 2: ''

```
[binaryValue.zip](https://github.com/cocotb/cocotb/files/5701238/binaryValue.zip)

Running on ModelSim DE version 2020.4 2020.10 (32bit)
Running tests with cocotb v1.5.0.dev0
OS: CentOS 64bit



Discussion:
Ha, I was in the process of typing up #2295 when you opened this issue.
Interesting that this raises a ValueError, seems like this may just be a side-effect of how binaryvalue is currently implemented.
> So, this issue was reported recently against cocotbext-axi: alexforencich/cocotbext-axi#8
> 
> Apparently, in VHDL, it is possible to have signals with length 0, and while cocotb can read such signals, converting the value to integer fails.
> 
> This is not necessarily a major problem on its own, but it becomes more annoying when said signal gets attached to a bus, as now one element in the bus cannot be converted to an integer.
> 
> It's unclear what the most appropriate fix would be. Personally, I think a zero-length signal should convert to 0. But perhaps there are some good reasons for it to not convert at all. Another possible fix would be to add a check to the bus module to throw out zero length signals, possibly based on an argument to __init__. Perhaps another option is to manually delete zero-length signals from the bus object after it is initialized, but IMO this is a bit of a hack and would require poking around in the internal state of the bus object.

Originally posted by @alexforencich in https://github.com/cocotb/cocotb/issues/2295#issue-768599978
The current behavior makes sense to me.

`BinaryValue` can handle 0-length signals, and I assume using the `.binstr` property on that BinaryValue works? It's only when you attempt to cast it to an integer with `int()` or use the `.integer` and `.signed_integer` properties that it throws an exception? You have no value and you are casting it to a type that cannot represent a lack of value, so it seems logical to throw. `0` may be convenient, but shadows the issue of the user probably doing the wrong thing.

I think the user or Bus type should handle these cases.
I agree with @alexforencich, and think such a signal should read as zero. The only reason this fails right now is because `int(str)` is designed for interactive use, and a user entering an empty string is likely a mistake. Compare to `int.from_bytes`, where a string of zero bytes is happily converted to zero:
```python
>>> int.from_bytes(b'', byteorder='little')
0
```

> you are casting it to a type that cannot represent a lack of value

A zero-bit signal is not the `empty` type with no values, it's the `unit` type with exactly one value.
> Compare to int.from_bytes, where a string of zero bytes is happily converted to zero

Seems inconsistent to me. But no good programming language is complete until it has a myriad of inconsistent interfaces that everyone has to remember.

> A zero-bit signal is not the empty type with no values, it's the unit type with exactly one value.

Right, and there is exactly 1 BinaryValue value to represent this state. For `0` to make sense the following would also have to work, otherwise the behavior is asymmetric.

```python
dut.empty_signal <= 0
dut.empty_signal <= BinaryValue(value=0, n_bits=0)
```
Yes, I think both of those should be legal too; `dut.empty_signal == 0` should always be true, and `dut.empty_signal <= 0` should always be permitted. `dut.empty_signal <= 1` is obviously an "out of range" error.
Well as long as it's consistent I guess I'm okay with it too. @Botnic @alexforencich Could one of you make the changes with a test for this case?
I like this change.  I occasionally have ports that are indexing something, and when there is only one item to index they have a width of 0.  Currently I need extra logic in the tests for this special case, but with this change I'll be able to remove it.

Resolving patch:
diff --git a/cocotb/binary.py b/cocotb/binary.py
--- a/cocotb/binary.py
+++ b/cocotb/binary.py
@@ -193,6 +193,8 @@ def assign(self, value):
             )
 
     def _convert_to_unsigned(self, x):
+        if x == 0:
+            return self._adjust_unsigned("")
         x = bin(x)
         if x[0] == '-':
             raise ValueError('Attempt to assigned negative number to unsigned '
@@ -200,6 +202,8 @@ def _convert_to_unsigned(self, x):
         return self._adjust_unsigned(x[2:])
 
     def _convert_to_signed_mag(self, x):
+        if x == 0:
+            return self._adjust_unsigned("")
         x = bin(x)
         if x[0] == '-':
             binstr = self._adjust_signed_mag('1' + x[3:])
@@ -213,6 +217,8 @@ def _convert_to_twos_comp(self, x):
         if x < 0:
             binstr = bin(2 ** (_clog2(abs(x)) + 1) + x)[2:]
             binstr = self._adjust_twos_comp(binstr)
+        elif x == 0:
+            binstr = self._adjust_twos_comp("")
         else:
             binstr = self._adjust_twos_comp('0' + bin(x)[2:])
         if self.big_endian:
@@ -220,15 +226,21 @@ def _convert_to_twos_comp(self, x):
         return binstr
 
     def _convert_from_unsigned(self, x):
+        if not len(x):
+            return 0
         return int(x.translate(_resolve_table), 2)
 
     def _convert_from_signed_mag(self, x):
+        if not len(x):
+            return 0
         rv = int(self._str[1:].translate(_resolve_table), 2)
         if self._str[0] == '1':
             rv = rv * -1
         return rv
 
     def _convert_from_twos_comp(self, x):
+        if not len(x):
+            return 0
         if x[0] == '1':
             binstr = x[1:]
             binstr = self._invert(binstr)
@@ -278,7 +290,7 @@ def _adjust_signed_mag(self, x):
         if self._n_bits is None:
             return x
         l = len(x)
-        if l <= self._n_bits:
+        if l < self._n_bits:
             if self.big_endian:
                 rv = x[:-1] + '0' * (self._n_bits - 1 - l)
                 rv = rv + x[-1]
@@ -300,7 +312,9 @@ def _adjust_twos_comp(self, x):
         if self._n_bits is None:
             return x
         l = len(x)
-        if l <= self._n_bits:
+        if l == 0:
+            rv = x
+        elif l < self._n_bits:
             if self.big_endian:
                 rv = x + x[-1] * (self._n_bits - l)
             else:


Test patch:
diff --git a/tests/pytest/test_binary_value.py b/tests/pytest/test_binary_value.py
--- a/tests/pytest/test_binary_value.py
+++ b/tests/pytest/test_binary_value.py
@@ -8,6 +8,39 @@
 TRUNCATION_MATCH = r"\d+-bit value requested, truncating value"
 
 
+def test_init_zero_length():
+    bin1 = BinaryValue(value=0, n_bits=0, binaryRepresentation=BinaryRepresentation.UNSIGNED)
+    assert bin1._str == ""
+    assert bin1.binstr == ""
+    assert bin1.integer == 0
+
+    bin2 = BinaryValue(value=0, n_bits=0,
+                       binaryRepresentation=BinaryRepresentation.TWOS_COMPLEMENT)
+    assert bin2._str == ""
+    assert bin2.binstr == ""
+    assert bin2.integer == 0
+
+    bin3 = BinaryValue(value=0, n_bits=0,
+                       binaryRepresentation=BinaryRepresentation.SIGNED_MAGNITUDE)
+    assert bin3._str == ""
+    assert bin3.binstr == ""
+    assert bin3.integer == 0
+
+    # Whatever value is set to a zero bit long BinaryValue, it should read 0
+    with pytest.warns(RuntimeWarning, match=TRUNCATION_MATCH):
+        bin4 = BinaryValue(value=10, n_bits=0,
+                           binaryRepresentation=BinaryRepresentation.SIGNED_MAGNITUDE)
+    assert bin4._str == ""
+    assert bin4.binstr == ""
+    assert bin4.integer == 0
+
+    with pytest.warns(RuntimeWarning, match=TRUNCATION_MATCH):
+        bin4 <= 5
+    assert bin4._str == ""
+    assert bin4.binstr == ""
+    assert bin4.integer == 0
+
+
 def test_init_big_endian_twos_comp():
     bin1 = BinaryValue(value=-1, n_bits=2, bigEndian=True, binaryRepresentation=BinaryRepresentation.TWOS_COMPLEMENT)
     assert bin1._str == "11"
diff --git a/tests/test_cases/test_vhdl_zerovector/Makefile b/tests/test_cases/test_vhdl_zerovector/Makefile
new file mode 100644
--- /dev/null
+++ b/tests/test_cases/test_vhdl_zerovector/Makefile
@@ -0,0 +1,24 @@
+# Copyright cocotb contributors
+# Licensed under the Revised BSD License, see LICENSE for details.
+# SPDX-License-Identifier: BSD-3-Clause
+
+ifeq ($(TOPLEVEL_LANG),vhdl)
+
+ifneq ($(filter $(SIM),ius xcelium),)
+    SIM_ARGS += -v93
+endif
+
+TOPLEVEL := vhdl_zerovector
+MODULE := test_vhdl_zerovector
+VHDL_SOURCES := vhdl_zerovector.vhdl
+
+# Cocotb inclusions
+include $(shell cocotb-config --makefiles)/Makefile.sim
+
+else
+all:
+	$(info Skipping simulation as only TOPLEVEL_LANG=vhdl is supported)
+
+clean::
+
+endif
diff --git a/tests/test_cases/test_vhdl_zerovector/test_vhdl_zerovector.py b/tests/test_cases/test_vhdl_zerovector/test_vhdl_zerovector.py
new file mode 100644
--- /dev/null
+++ b/tests/test_cases/test_vhdl_zerovector/test_vhdl_zerovector.py
@@ -0,0 +1,39 @@
+# Copyright cocotb contributors
+# Licensed under the Revised BSD License, see LICENSE for details.
+# SPDX-License-Identifier: BSD-3-Clause
+
+import cocotb
+from cocotb.triggers import Timer
+import pytest
+
+
+@cocotb.test()
+async def test_long_signal(dut):
+    """ Write and read a normal signal (longer than 0)."""
+    dut.data_in <= 0x5
+    await Timer(1, "ns")
+    assert dut.data_out == 0x5, "Failed to readback dut.data_out"
+
+
+@cocotb.test(expect_error=AttributeError if cocotb.SIM_NAME.lower().startswith(
+    ("ghdl", "xmsim", "ncsim", "riviera", "aldec")) else ())
+async def test_read_zero_signal(dut):
+    """ Read a zero vector. It should always read 0."""
+    assert dut.Cntrl_out == 0, "Failed to readback dut.Cntrl_out"
+
+
+@cocotb.test(expect_error=AttributeError if cocotb.SIM_NAME.lower().startswith(
+    ("ghdl", "xmsim", "ncsim", "riviera", "aldec")) else ())
+async def test_write_zero_signal_with_0(dut):
+    """ Write a zero vector with 0."""
+    dut.Cntrl_out <= 0x0
+    await Timer(1, "ns")
+    assert dut.Cntrl_out == 0, "Failed to readback dut.Cntrl_out"
+
+
+@cocotb.test(expect_error=AttributeError if cocotb.SIM_NAME.lower().startswith(
+    ("ghdl", "xmsim", "ncsim", "riviera", "aldec")) else ())
+async def test_write_zero_signal_with_1(dut):
+    """ Write a zero vector with 1. Should catch a "out of range" exception."""
+    with pytest.raises(OverflowError):
+        dut.Cntrl_out <= 0x1
diff --git a/tests/test_cases/test_vhdl_zerovector/vhdl_zerovector.vhdl b/tests/test_cases/test_vhdl_zerovector/vhdl_zerovector.vhdl
new file mode 100644
--- /dev/null
+++ b/tests/test_cases/test_vhdl_zerovector/vhdl_zerovector.vhdl
@@ -0,0 +1,21 @@
+-- Copyright cocotb contributors
+-- Licensed under the Revised BSD License, see LICENSE for details.
+-- SPDX-License-Identifier: BSD-3-Clause
+
+library ieee;
+use ieee.std_logic_1164.all;
+
+entity vhdl_zerovector is
+  generic(DataWidth  : natural := 8;
+          CntrlWidth : natural := 0);
+  port(Data_in   : in  std_logic_vector(DataWidth - 1 downto 0);
+       Data_out  : out std_logic_vector(DataWidth - 1 downto 0);
+       Cntrl_in  : in  std_logic_vector(CntrlWidth - 1 downto 0);
+       Cntrl_out : out std_logic_vector(CntrlWidth - 1 downto 0));
+end entity vhdl_zerovector;
+
+architecture RTL of vhdl_zerovector is
+begin
+  Data_out  <= Data_in;
+  Cntrl_out <= Cntrl_in;
+end architecture RTL;