Repository: Qiskit/qiskit

Issue:
Truncating floats in QASM dump alters circuit's functionality
### Information

- **Qiskit Terra version**: 0.18.3 (dates back to 0.14)
- **Python version**: Any
- **Operating system**: Any

### What is the current behavior?
In #4276, the `pi_check` function was introduced for pretty printing the list of parameters when dumping a `QuantumCircuit` to `qasm`, see 
https://github.com/Qiskit/qiskit-terra/blob/637acc0c408d8199dd58780cc3dafabc14e61a0f/qiskit/circuit/instruction.py#L450-L463
This also introduced a fixed truncation (`ndigits=8`) of floating point numbers that are not close to any fraction or fraction of pi. However, *8* digits is not enough to uniquely identify a double precision floating point number. You need up to *17* significant digits for that. This leads to the following situation:

Let's say I have a circuit that contains, e.g., a phase gate with more than 8 significant digits in its rotation angle.
Then, I dump the circuit to a `.qasm` file and read that file back into a `QuantumCircuit`.
If I compare both unitary functionalities (i.e., the respective unitary matrices), these will no longer be equal due to the numerical error introduced by the truncation.

### Steps to reproduce the problem

```python
from qiskit import QuantumCircuit, execute, Aer

qc = QuantumCircuit(1)
qc.p(1/4096, 0)

qasm_str = qc.qasm()

backend = Aer.get_backend('aer_simulator')

qc.save_unitary()
job = execute(qc, backend)
result = job.result()
unitary = result.get_unitary(qc)

qc_qasm = QuantumCircuit.from_qasm_str(qasm_str)
qc_qasm.save_unitary()
job = execute(qc_qasm, backend)
result = job.result()
unitary_qasm = result.get_unitary(qc_qasm)
    
print(unitary == unitary_qasm)
```
yields
```bash
[[ True  True]
 [ True False]]
```

### What is the expected behavior?

Merely dumping a file to `.qasm` should not alter a circuit's functionality.

### Suggested solutions

A quick fix for this would be to just change the default value for the `ndigits` parameter in the respective function call to `17`.



Discussion:
This is very true, and I'm not a fan of `pi_check` in general for anything other than pretty visualisations.  In part, I think things were done like this because serialisation to and from OpenQASM 2 was never intended to be completely lossless (various Terra operations simply can't be represented in OpenQASM 2 already), and at the time, hardware probably wasn't capable of realising the difference in pulses at that level of precision.  It's not an ideal choice now, for sure.

We can look into raising the number of digits here.  Due to some unfortunate coupling with other parts of the code, I think it possibly has a couple of knock-on changes too.  Our new OpenQASM 3 exporter is a bit more separated out from the Terra internals, which will hopefully mean things like this will be far more controllable once that arrives (see #6565, due to land in Terra 0.19).

I will say that "dumping a file to `.qasm` should not alter a circuit's functionality" is slightly mistaken, though - OpenQASM 2 is not a serialisation format for Terra circuits, it's just an output format we support.  If you need complete serialisation, try `qiskit.circuit.qpy_serialization.{dump,load}` - this is a complete binary format.
I see your point that OpenQASM 2 is not intended as a lossless serialisation option. However, in this case, where this is not an inherent limitation of the OpenQASM 2 specification, I believe it would be beneficial to fix this.
I would have expected this to be a rather local issue in the `Instruction.qasm()` routine (i.e., just changing the magic number there), but then again I am not that familiar with the particular details.

Great to see, that OpenQASM 3 export is coming. Looking forward to that!
pi_check serves a nice purpose in finding fractions of pi, whether for visualization or qasm output. The ndigits is just there to tell pi_check what to do with the float if no fraction of pi is found. In visualizations, this is set to something that looks good in the display. For qasm, it really should be up to the user how many float decimals are included in the output, since I doubt everyone needs 17, so I'd recommend adding a kwarg to Instruction.qasm, to let the user choose.

Of course, it doesn't solve the problem that OpenQASM 2 and QuantumCircuit are not one-to-one, but it would give users more flexibility.
I tend to agree that having lossless-as-possible mode for qasm export is a useful enhancement. Both for the reason of interfacing with external tools, and also for debug purposes.
I'm on board with adding more precision to QASM 2 output, I'm just not certain the best way to go about it here.  We can't (officially) add anything to the signature of `Instruction.qasm`, because that's a public interface that we expect people to inherit from, so starting to pass it extra keywords would break compatibility.

A couple of ways we _could_ go:
- break compatibility anyway, and just wrap calls to `Instruction.qasm` in a `try: ...; except TypeError`.
- replace the `ndigits` in all Qiskit `Instruction.qasm` definitions with a reference to a global variable, context-managed by `QuantumCircuit.qasm`, which does accept the keyword.
- change the output to always use more digits in every case

None of those are super nice to me, but there may be other options.  The last is probably the nicest, but it doesn't solve the other underlying problem that calls to `pi_check` are hard-coded, so anything that falls close to a multiple of pi will get (very) lossily converted - we'd also have to change `eps`, but I think that's an absolute tolerance, whereas what we want here is really a ULP tolerance.

This functionality will (or should) be available in the OpenQASM 3 exporter.  The way internal instructions provide their QASM definition (at the moment) is by returning AST nodes, so that's a lossless conversion, and the printer is completely separated, and can take many more options - one of the ones already implemented is to completely disable all the `pi_check` conversions (which, to me at least, might want to be the default).  It should be not too hard to ensure that the default printing behaviour for floats is "enough digits to uniquely identify a single float with the given precision".
The second option certainly provides the most flexibility. `QuantumCircuit.qasm` could then just accept `ndigits` *and* `eps` keywords. Although, I am not so sure whether this flexibility is really needed.
One possibility could be to add a `numeric` flag/keyword to the `QuantumCircuit.qasm` function that just skips the `pi_check` call all together and just emits an as precise as possible floating point representation (i.e., up to 17 decimals) for every parameter.
Or the other way around: always print the numerically-accurate representation (skipping `pi_check`) and provide a flag/keyword for pretty printing/exporting (possibly at the cost of precision).

Having `pi_check` disabled per default sounds like a good idea for OpenQASM 3 export. Then, users can decide whether they want to pretty print/export their circuit at the cost of precision. 
The problem is that `QuantumCircuit.qasm` internally calls `Instruction.qasm` on all its data elements, which may be overridden by user classes as it's a public API that we allow/encourage subclassing.  We can't pass down any flags to _our_ classes and avoid raising `TypeError` in ones users have already written without a bunch of hackery (e.g. `try/except` to see if they support the parameter, or having our classes access global state under our control).  These issues are almost all about maintaining proper backwards compatibility in public APIs; if we were making these changes in private, internal interactions only, yeah, I agree, it's not a hard problem.  We can't have Terra 0.19 break valid code users have written for Terra 0.18 - we have to have at least a three-month deprecation period.
I get that. But then I would suggest the easiest workaround/fix that does not need any kind of deprecation would just be to change the magic `ndigits` constant in the `Instruction.qasm()` from *8* to *17*. 
In addition to that it might make sense to explicitly specify a smaller `eps` (something in the range of *1e-12* to *1e-14*), in order to lessen the impact of the approximation to fractions of pi.

In this fashion, no compatibility is broken and the export to OpenQASM 2 gets a lot more accurate / less lossy.
@jakelishman If @burgholzer's short-term solution is acceptable, I can take care of it.
I just ran into this bug/peculiarity of `Instruction.qasm()`, too. Any updates on @burgholzer's proposed short-term solution?
An `eps` value of 1e-12 in `pi_check` seems to work fine in my case.
Sorry this fell through the cracks.  I'm ok with accepting a PR that changes `eps` to be tighter here, if that helps people.
No worries. Would you also be willing to change `ndigits` from `8` to `17` in order to get `double` precision instead of `float` precision?

Resolving patch:
diff --git a/qiskit/circuit/instruction.py b/qiskit/circuit/instruction.py
--- a/qiskit/circuit/instruction.py
+++ b/qiskit/circuit/instruction.py
@@ -455,7 +455,7 @@ def qasm(self):
         if self.params:
             name_param = "{}({})".format(
                 name_param,
-                ",".join([pi_check(i, ndigits=8, output="qasm") for i in self.params]),
+                ",".join([pi_check(i, output="qasm", eps=1e-12) for i in self.params]),
             )
 
         return self._qasmif(name_param)
diff --git a/qiskit/circuit/tools/pi_check.py b/qiskit/circuit/tools/pi_check.py
--- a/qiskit/circuit/tools/pi_check.py
+++ b/qiskit/circuit/tools/pi_check.py
@@ -25,7 +25,7 @@
 POW_LIST = np.pi ** np.arange(2, 5)
 
 
-def pi_check(inpt, eps=1e-6, output="text", ndigits=5):
+def pi_check(inpt, eps=1e-9, output="text", ndigits=None):
     """Computes if a number is close to an integer
     fraction or multiple of PI and returns the
     corresponding string.
@@ -35,8 +35,10 @@ def pi_check(inpt, eps=1e-6, output="text", ndigits=5):
         eps (float): EPS to check against.
         output (str): Options are 'text' (default),
                       'latex', 'mpl', and 'qasm'.
-        ndigits (int): Number of digits to print
-                       if returning raw inpt.
+        ndigits (int or None): Number of digits to print
+                               if returning raw inpt.
+                               If `None` (default), Python's
+                               default float formatting is used.
 
     Returns:
         str: string representation of output.
@@ -66,7 +68,7 @@ def pi_check(inpt, eps=1e-6, output="text", ndigits=5):
         return inpt
 
     def normalize(single_inpt):
-        if abs(single_inpt) < 1e-14:
+        if abs(single_inpt) < eps:
             return "0"
 
         if output == "text":
@@ -101,7 +103,10 @@ def normalize(single_inpt):
             power = np.where(abs(abs(single_inpt) - POW_LIST) < eps)
             if power[0].shape[0]:
                 if output == "qasm":
-                    str_out = "{:.{}g}".format(single_inpt, ndigits)
+                    if ndigits is None:
+                        str_out = "{}".format(single_inpt)
+                    else:
+                        str_out = "{:.{}g}".format(single_inpt, ndigits)
                 elif output == "latex":
                     str_out = f"{neg_str}{pi}^{power[0][0] + 2}"
                 elif output == "mpl":
@@ -113,7 +118,10 @@ def normalize(single_inpt):
         # Third is a check for a number larger than MAX_FRAC * pi, not a
         # multiple or power of pi, since no fractions will exceed MAX_FRAC * pi
         if abs(single_inpt) >= (MAX_FRAC * np.pi):
-            str_out = "{:.{}g}".format(single_inpt, ndigits)
+            if ndigits is None:
+                str_out = "{}".format(single_inpt)
+            else:
+                str_out = "{:.{}g}".format(single_inpt, ndigits)
             return str_out
 
         # Fourth check is for fractions for 1*pi in the numer and any
@@ -160,7 +168,10 @@ def normalize(single_inpt):
             return str_out
 
         # Nothing found
-        str_out = "{:.{}g}".format(single_inpt, ndigits)
+        if ndigits is None:
+            str_out = "{}".format(single_inpt)
+        else:
+            str_out = "{:.{}g}".format(single_inpt, ndigits)
         return str_out
 
     complex_inpt = complex(inpt)


Test patch:
diff --git a/test/python/circuit/test_circuit_qasm.py b/test/python/circuit/test_circuit_qasm.py
--- a/test/python/circuit/test_circuit_qasm.py
+++ b/test/python/circuit/test_circuit_qasm.py
@@ -451,6 +451,36 @@ def test_circuit_qasm_with_duplicate_invalid_identifiers(self):
         for idx, instruction in enumerate(base._data):
             self.assertEqual(instruction.operation.name, names[idx])
 
+    def test_circuit_qasm_with_double_precision_rotation_angle(self):
+        """Test that qasm() emits high precision rotation angles per default."""
+        from qiskit.circuit.tools.pi_check import MAX_FRAC
+
+        qc = QuantumCircuit(1)
+        qc.p(0.123456789, 0)
+        qc.p(pi * pi, 0)
+        qc.p(MAX_FRAC * pi + 1, 0)
+
+        expected_qasm = """OPENQASM 2.0;
+include "qelib1.inc";
+qreg q[1];
+p(0.123456789) q[0];
+p(9.869604401089358) q[0];
+p(51.26548245743669) q[0];\n"""
+        self.assertEqual(qc.qasm(), expected_qasm)
+
+    def test_circuit_qasm_with_rotation_angles_close_to_pi(self):
+        """Test that qasm() properly rounds values closer than 1e-12 to pi."""
+
+        qc = QuantumCircuit(1)
+        qc.p(pi + 1e-11, 0)
+        qc.p(pi + 1e-12, 0)
+        expected_qasm = """OPENQASM 2.0;
+include "qelib1.inc";
+qreg q[1];
+p(3.141592653599793) q[0];
+p(pi) q[0];\n"""
+        self.assertEqual(qc.qasm(), expected_qasm)
+
     def test_circuit_raises_on_single_bit_condition(self):
         """OpenQASM 2 can't represent single-bit conditions, so test that a suitable error is
         printed if this is attempted."""
diff --git a/test/python/circuit/test_tools.py b/test/python/circuit/test_tools.py
--- a/test/python/circuit/test_tools.py
+++ b/test/python/circuit/test_tools.py
@@ -31,21 +31,21 @@ class TestPiCheck(QiskitTestCase):
             (3.141592653589793, "π"),
             (6.283185307179586, "2π"),
             (2.99, "2.99"),
-            (2.999999999999999, "3"),
+            (2.9999999999999999, "3.0"),
             (0.99, "0.99"),
-            (0.999999999999999, "1"),
+            (0.99999999999999999, "1.0"),
             (pi, "π"),
             (-pi, "-π"),
             (3 * pi, "3π"),
             (-3 * pi, "-3π"),
             (pi / 35, "π/35"),
             (-pi / 35, "-π/35"),
-            (3 * pi / 35, "0.26928"),
-            (-3 * pi / 35, "-0.26928"),
+            (3 * pi / 35, "0.26927937030769655"),
+            (-3 * pi / 35, "-0.26927937030769655"),
             (pi**2, "π**2"),
             (-(pi**2), "-π**2"),
-            (1e9, "1e+09"),
-            (-1e9, "-1e+09"),
+            (1e9, "1000000000.0"),
+            (-1e9, "-1000000000.0"),
             (1e-9, "1e-09"),
             (-1e-9, "-1e-09"),
             (6 * pi / 11, "6π/11"),
@@ -64,8 +64,8 @@ class TestPiCheck(QiskitTestCase):
             (-1 / pi, "-1/π"),
             (6 / (5 * pi), "6/5π"),
             (-6 / (5 * pi), "-6/5π"),
-            (-382578.0 + 0.0234567j, "-3.8258e+05+0.023457j"),
-            (-382578.0 - 0.0234567j, "-3.8258e+05-0.023457j"),
+            (-382578.0 + 0.0234567j, "-382578.0+0.0234567j"),
+            (-382578.0 - 0.0234567j, "-382578.0-0.0234567j"),
         ]
     )
     def test_default(self, case):