Esercitazione: Scrivere e simulare programmi a livello di qubit in Q#

Questa esercitazione illustra come scrivere e simulare un programma quantistico di base che opera su singoli qubit.

Anche se Q# è stato creato principalmente come linguaggio di programmazione di alto livello per programmi quantistici su larga scala, può essere usato anche per esplorare il livello inferiore della programmazione quantistica, cioè l'indirizzamento diretto di qubit specifici. In particolare, questa esercitazione approfondisce la trasformata di Fourier quantistica (QFT), una subroutine che è parte integrante di molti algoritmi quantistici più grandi.

Nota

Questa visualizzazione di livello inferiore dell'elaborazione di informazioni quantistiche viene spesso descritta in termini di circuiti quantistici, che rappresentano l'applicazione sequenziale di controlli, o operazioni, a qubit specifici di un sistema. Di conseguenza, le operazioni a singolo qubit e a più qubit applicate in sequenza possono essere immediatamente rappresentate in diagrammi di circuito. Ad esempio, la trasformazione quantum Fourier a tre qubit completa usata in questa esercitazione include la rappresentazione seguente come circuito: Diagramma di un circuito Quantum Fourier Transform.

In questa esercitazione si apprenderà come

  • Definire le operazioni quantistiche in Q#
  • Chiamare le operazioni Q# direttamente dal prompt dei comandi o usando un programma host classico
  • Simulare un'operazione quantistica dall'allocazione di qubit all'output della misurazione
  • Osservare l'evoluzione della funzione d'onda simulata del sistema quantistico durante l'operazione

Prerequisiti

Allocare qubit e definire operazioni quantistiche

La prima parte di questa esercitazione consiste nella definizione dell'operazione Perform3qubitQFT di Q#, che esegue la trasformata di Fourier quantistica su tre qubit. La funzione DumpMachine viene usata per osservare l'evoluzione della funzione d'onda simulata del sistema a tre qubit nell'operazione. Nella seconda parte dell'esercitazione si aggiungerà la funzionalità di misurazione e si confronteranno gli stati pre-misurazione e post-misurazione dei qubit.

L'operazione verrà compilata passo per passo. Tuttavia, è possibile visualizzare il codice Q# completo per questa sezione come riferimento.

Spazi dei nomi per accedere ad altre operazioni Q#

All'interno del file Q# definire lo spazio dei nomi NamespaceQFT, a cui accede il compilatore. Per fare in modo che questa operazione usi le operazioni Q# esistenti, aprire gli spazi dei nomi Microsoft.Quantum.<> pertinenti.

namespace NamespaceQFT {
    open Microsoft.Quantum.Intrinsic;
    open Microsoft.Quantum.Diagnostics;
    open Microsoft.Quantum.Math;
    open Microsoft.Quantum.Arrays;

    // operations go here
}

Definire operazioni con argomenti e risultati restituiti

Definire quindi l'operazione Perform3qubitQFT:

    operation Perform3qubitQFT() : Unit {
        // do stuff
    }

Per il momento, l'operazione non accetta argomenti e restituisce un oggetto Unit, che è analogo alla restituzione di void in C# o di una tupla vuota, Tuple[()], in Python. In seguito si modificherà l'operazione in modo che restituisca una matrice di risultati della misurazione.

Allocare qubit con use

All'interno dell'operazione Q# allocare un registro di tre qubit con la parola chiave use:

        use qs = Qubit[3];

        Message("Initial state |000>:");
        DumpMachine();

Con use, i qubit vengono allocati automaticamente nello stato $\ket{0}$. È possibile verificarne lo stato allocato usando DumpMachine, che riporta lo stato corrente del sistema nella console.

Nota

Come in un calcolo quantistico reale, Q# non consente di accedere direttamente agli stati del qubit. Tuttavia, quando DumpMachine riporta lo stato corrente del computer di destinazione, può fornire informazioni utili per il debug e l'apprendimento se usato in combinazione con il simulatore di stato completo.

Applicazione di operazioni controllate e a singolo qubit

Applicare quindi le operazioni che costituiscono l'operazione Perform3qubitQFT stessa. Q# contiene già queste e molte altre operazioni quantistiche di base nello spazio dei nomi Microsoft.Quantum.Intrinsic.

La prima operazione applicata è l'operazione H (Hadamard) al primo qubit:

Diagramma che mostra un circuito per tre qubit QFT attraverso il primo Hadamard.

Per applicare un'operazione a un qubit specifico da un registro (ad esempio, un singolo Qubit da una matrice Qubit[]), usare la notazione di indice standard. Quindi, l'applicazione dell'operazione H al primo qubit del registro qs assume il formato seguente:

            H(qs[0]);

Oltre ad applicare l'operazione H ai singoli qubit, il circuito QFT è costituito principalmente da rotazioni R1 controllate. Un'operazione R1(θ, <qubit>) in generale lascia invariato il componente $\ket{0}$ del qubit durante l'applicazione di una rotazione di $e^{i\theta}$ al componente $\ket{1}$.

Operazioni controllate

Q# semplifica il condizionamento dell'esecuzione di un'operazione su uno o più qubit di controllo. In generale, la chiamata è preceduta da Controlled e gli argomenti dell'operazione cambiano come segue:

Op(<normal args>) $\to$ Controlled Op([<control qubits>], (<normal args>)).

Si noti che l'argomento qubit di controllo deve essere una matrice, anche se è per un singolo qubit.

Le operazioni successive sono le operazioni R1 che agiscono sul primo qubit (e che sono controllate dal secondo e dal terzo qubit):

Diagramma che mostra un circuito per tre qubit Quantum Fourier Transform tramite il primo qubit.

Nel file Q# chiamare queste operazioni con queste istruzioni:

            Controlled R1([qs[1]], (PI()/2.0, qs[0]));
            Controlled R1([qs[2]], (PI()/4.0, qs[0]));

La funzione PI() viene usata per definire le rotazioni in termini di radianti pi greco.

Dopo aver applicato le operazioni H pertinenti e le rotazioni controllate al secondo e al terzo qubit,

            //second qubit:
            H(qs[1]);
            Controlled R1([qs[2]], (PI()/2.0, qs[1]));

            //third qubit:
            H(qs[2]);

è necessario solo applicare un'operazione SWAP per completare il circuito:

            SWAP(qs[2], qs[0]);

Ciò è necessario perché la natura della trasformata di Fourier quantistica restituisce i qubit in ordine inverso, quindi gli scambi consentono una perfetta integrazione della subroutine in algoritmi più grandi.

A questo punto è stata completata la scrittura delle operazioni a livello di qubit della trasformata di Fourier quantistica nell'operazione Q#:

Diagramma che mostra un circuito per tre qubit Quantum Fourier Transform.

Deallocare i qubit

L'ultimo passaggio consiste nel chiamare di nuovo DumpMachine() per visualizzare lo stato post-operazione e per deallocare i qubit. Al momento dell'allocazione i qubit si trovavano nello stato $\ket{0}$ e devono essere reimpostati allo stato iniziale usando l'operazione ResetAll.

La richiesta che tutti i qubit deallocati siano impostati in modo esplicito su $\ket{0}$ è una funzionalità di base di Q#, perché consente ad altre operazioni di conoscerne esattamente lo stato quando iniziano a usare gli stessi qubit (una risorsa scarsa). Inoltre, ciò garantisce che non ci siano entanglement con altri qubit nel sistema. Se la reimpostazione non viene eseguita alla fine di un blocco di allocazione use, potrebbe essere generato un errore di runtime.

Aggiungere la riga seguente al file Q#:

            Message("After:");
            DumpMachine();

            ResetAll(qs);

L'operazione completata

A questo punto il file Q# dovrebbe avere un aspetto simile al seguente:

namespace NamespaceQFT {
    open Microsoft.Quantum.Intrinsic;
    open Microsoft.Quantum.Diagnostics;
    open Microsoft.Quantum.Math;
    open Microsoft.Quantum.Arrays;

    operation Perform3qubitQFT() : Unit {

        use qs = Qubit[3];

        Message("Initial state |000>:");
        DumpMachine();

        //QFT:
        //first qubit:
        H(qs[0]);
        Controlled R1([qs[1]], (PI()/2.0, qs[0]));
        Controlled R1([qs[2]], (PI()/4.0, qs[0]));

        //second qubit:
        H(qs[1]);
        Controlled R1([qs[2]], (PI()/2.0, qs[1]));

        //third qubit:
        H(qs[2]);

        SWAP(qs[2], qs[0]);

        Message("After:");
        DumpMachine();

        ResetAll(qs);

    }
}

Al termine del file Q# e dell'operazione, il programma quantistico è pronto per essere chiamato e simulato.

Testare l'operazione

Dopo aver definito l'operazione Q# in un file .qs, è necessario chiamare l'operazione e osservare gli eventuali dati classici restituiti. Per il momento, l'operazione non restituisce alcun valore (tenere presente che l'operazione definita in precedenza restituisce Unit). In seguito si modificherà l'operazione in modo che restituisca una matrice di risultati della misurazione (Result[]).

Anche se il programma Q# è onnipresente negli ambienti usati per chiamarlo, il modo di farlo varia. Di conseguenza, seguire le istruzioni nella scheda corrispondente alla configurazione, lavorando dall'applicazione Q# o usando un programma host in Python o C#.

L'esecuzione del programma Q# dal prompt dei comandi richiede una piccola modifica al file Q#, aggiungendo un @EntryPoint() alla riga che precede l'operazione che si vuole eseguire:

    @EntryPoint()
    operation Perform3qubitQFT() : Unit {
        // ...

Per eseguire il programma, aprire il terminale nella cartella del progetto e immettere

dotnet run

Al termine, nella console dovrebbero essere visualizzati gli output Message e DumpMachine.

Initial state |000>:
# wave function for qubits with ids (least to most significant): 0;1;2
|0>:	 1.000000 +  0.000000 i	 == 	******************** [ 1.000000 ]     --- [  0.00000 rad ]
|1>:	 0.000000 +  0.000000 i	 == 	                     [ 0.000000 ]                   
|2>:	 0.000000 +  0.000000 i	 == 	                     [ 0.000000 ]                   
|3>:	 0.000000 +  0.000000 i	 == 	                     [ 0.000000 ]                   
|4>:	 0.000000 +  0.000000 i	 == 	                     [ 0.000000 ]                   
|5>:	 0.000000 +  0.000000 i	 == 	                     [ 0.000000 ]                   
|6>:	 0.000000 +  0.000000 i	 == 	                     [ 0.000000 ]                   
|7>:	 0.000000 +  0.000000 i	 == 	                     [ 0.000000 ]                   
After:
# wave function for qubits with ids (least to most significant): 0;1;2
|0>:	 0.353553 +  0.000000 i	 == 	***                  [ 0.125000 ]     --- [  0.00000 rad ]
|1>:	 0.353553 +  0.000000 i	 == 	***                  [ 0.125000 ]     --- [  0.00000 rad ]
|2>:	 0.353553 +  0.000000 i	 == 	***                  [ 0.125000 ]     --- [  0.00000 rad ]
|3>:	 0.353553 +  0.000000 i	 == 	***                  [ 0.125000 ]     --- [  0.00000 rad ]
|4>:	 0.353553 +  0.000000 i	 == 	***                  [ 0.125000 ]     --- [  0.00000 rad ]
|5>:	 0.353553 +  0.000000 i	 == 	***                  [ 0.125000 ]     --- [  0.00000 rad ]
|6>:	 0.353553 +  0.000000 i	 == 	***                  [ 0.125000 ]     --- [  0.00000 rad ]
|7>:	 0.353553 +  0.000000 i	 == 	***                  [ 0.125000 ]     --- [  0.00000 rad ]

Informazioni sull'output

Quando viene chiamato nel simulatore di stato completo, DumpMachine() fornisce queste rappresentazioni multiple della funzione d'onda dello stato quantistico. I possibili stati di un sistema $n$-qubit possono essere rappresentati da stati di base computazionali $2^n$, ognuno con un coefficiente complesso corrispondente (ampiezza e fase). Gli stati di base computazionali corrispondono a tutte le possibili stringhe binarie di lunghezza $n$, cioè tutte le possibili combinazioni di stati di qubit $\ket{0}$ e $\ket{1}$, in cui ogni cifra binaria corrisponde a un singolo qubit.

La prima riga fornisce un commento con gli ID dei qubit corrispondenti nell'ordine significativo. Se il qubit 2 è "il più significativo", significa che nella rappresentazione binaria del vettore di stato di base $\ket{i}$, lo stato del qubit 2 corrisponde alla cifra più a sinistra. Ad esempio, $\ket{6} = \ket{110}$ include qubit 2 e 1 in $\ket{1}$ e qubit 0 in $\ket{0}$.

Le altre righe descrivono l'ampiezza della probabilità di misurare il vettore di stato di base $\ket{n}$ in formato sia cartesiano che polare. Esame della prima riga per lo stato di input $\ket{000}$:

  • |0>: corrisponde allo stato di base computazionale 0 (dato che lo stato iniziale dopo l'allocazione era $\ket{000}$, a questo punto si prevede che sia l'unico stato con ampiezza di probabilità).
  • 1.000000 + 0.000000 i: ampiezza della probabilità in formato cartesiano.
  • ==: il segno equal separa entrambe le rappresentazioni equivalenti.
  • ********************: rappresentazione grafica della grandezza. Il numero di * è proporzionale alla probabilità di misurare questo vettore di stato.
  • [ 1.000000 ]: valore numerico della grandezza.
  • ---: rappresentazione grafica della fase dell'ampiezza.
  • [ 0.0000 rad ]: valore numerico della fase (in radianti).

Sia la grandezza che la fase vengono visualizzate con una rappresentazione grafica. La rappresentazione della grandezza è diretta: mostra una barra di * e maggiore è la probabilità, maggiore sarà la barra.

L'output visualizzato mostra che le operazioni programmate hanno trasformato lo stato da

$$ \ket{\psi}_{initial} = \ket{000} $$

in

$$ \begin{align} \ket{\psi}_{final} &= \frac{1}{\sqrt{8}} \left( \ket{000} + \ket{001} + \ket{010} + \ket{011} + \ket{100} + \ket{101} + \ket{110} + \ket{111} \right) \\ &= \frac{1}{\sqrt{2^n}}\sum_{j=0}^{2^n-1} \ket{j}, \end{align} $$

che è esattamente il comportamento della trasformata di Fourier a tre qubit.

Se si desidera sapere come sono interessati gli altri stati di input, è consigliabile sperimentarlo applicando altre operazioni di qubit prima della trasformazione.

Aggiungere misurazioni

Quanto visualizzato dalla funzione DumpMachine ha mostrato i risultati dell'operazione, ma purtroppo un elemento fondamentale della meccanica quantistica stabilisce che un sistema quantistico reale non può avere tale funzione DumpMachine. Al contrario, le informazioni vengono estratte tramite misurazioni, che in generale non solo non forniscono informazioni sullo stato quantistico completo, ma possono anche modificare drasticamente il sistema stesso. Esistono molti tipi di misurazioni quantistiche, ma questo esempio è incentrato sulle misurazioni più semplici, quelle proiettive su singoli qubit. Al momento della misurazione in una determinata base (ad esempio, la base computazionale $ { \ket{0}, \ket{1} } $), lo stato del qubit viene proiettato su qualsiasi stato di base misurato, di conseguenza elimina qualsiasi sovrapposizione tra i due.

Modificare l'operazione

Per implementare le misurazioni all'interno di un programma Q#, usare l'operazione M, che restituisce un tipo Result.

Prima modificare l'operazione Perform3QubitQFT in modo che restituisca una matrice di risultati della misurazione (Result[] invece di Unit).

    operation Perform3QubitQFT() : Result[] {

Definire e inizializzare una matrice Result[]

Prima di allocare i qubit (ad esempio, prima dell'istruzione use), dichiarare e associare una matrice a tre elementi (un Result per ogni qubit):

        mutable resultArray = [Zero, size = 3];

La parola chiave mutable che precede resultArray consente di modificare la variabile in un secondo momento nel codice, ad esempio quando si aggiungono i risultati della misurazione.

Eseguire misurazioni in un ciclo for e aggiungere i risultati alla matrice

Dopo le operazioni di trasformata di Fourier, inserire il codice seguente:

            for i in IndexRange(qs) {
                set resultArray w/= i <- M(qs[i]);
            }

La funzione IndexRange chiamata in una matrice (ad esempio, la matrice di qubit, qs) restituisce un intervallo sugli indici della matrice. In questo caso, viene usato nel ciclo for per misurare in sequenza ogni qubit che usa l'istruzione M(qs[i]). Ogni tipo Result misurato (Zero o One) viene quindi aggiunto alla posizione di indice corrispondente in resultArray con un'istruzione update-and-reassign.

Nota

La sintassi di questa istruzione è univoca per Q#, ma corrisponde alla riassegnazione di variabili simili resultArray[i] <- M(qs[i]) vista in altri linguaggi, ad esempio F# e R.

La parola chiave set viene sempre usata per riassegnare le variabili associate tramite mutable.

Restituire resultArray

Con tutti e tre i qubit misurati e i risultati aggiunti a resultArray, è possibile reimpostare e deallocare i qubit come prima in modo sicuro. Per restituire le misurazioni, inserire:

        return resultArray;

Eseguire le misurazioni

Ora modificare la posizione delle funzioni DumpMachine in modo da restituire lo stato prima e dopo le misurazioni. Il codice Q# finale avrà un aspetto simile al seguente:

namespace NamespaceQFT {
    open Microsoft.Quantum.Intrinsic;
    open Microsoft.Quantum.Diagnostics;
    open Microsoft.Quantum.Math;
    open Microsoft.Quantum.Arrays;

    @EntryPoint()
    operation Perform3QubitQFT() : Result[] {

        mutable resultArray = [Zero, size = 3];

        use qs = Qubit[3];

        //QFT:
        //first qubit:
        H(qs[0]);
        Controlled R1([qs[1]], (PI()/2.0, qs[0]));
        Controlled R1([qs[2]], (PI()/4.0, qs[0]));

        //second qubit:
        H(qs[1]);
        Controlled R1([qs[2]], (PI()/2.0, qs[1]));

        //third qubit:
        H(qs[2]);

        SWAP(qs[2], qs[0]);

        Message("Before measurement: ");
        DumpMachine();

        for i in IndexRange(qs) {
            set resultArray w/= i <- M(qs[i]);
        }

        Message("After measurement: ");
        DumpMachine();

        ResetAll(qs);

        return resultArray;

    }
}

Se si sta lavorando dal prompt dei comandi, la matrice restituita viene visualizzata direttamente nella console alla fine dell'esecuzione. In caso contrario, aggiornare il programma host in modo da elaborare la matrice restituita.

Per comprendere meglio la matrice restituita stampata nella console, è possibile aggiungere un altro Message nel file Q# subito prima dell'istruzione return:

        Message("Post-QFT measurement results [qubit0, qubit1, qubit2]: ");
        return resultArray;

Eseguendo il progetto, l'output dovrebbe essere simile al seguente:

Before measurement: 
# wave function for qubits with ids (least to most significant): 0;1;2
|0>:     0.353553 +  0.000000 i  ==     ***                  [ 0.125000 ]     --- [  0.00000 rad ]
|1>:     0.353553 +  0.000000 i  ==     ***                  [ 0.125000 ]     --- [  0.00000 rad ]
|2>:     0.353553 +  0.000000 i  ==     ***                  [ 0.125000 ]     --- [  0.00000 rad ]
|3>:     0.353553 +  0.000000 i  ==     ***                  [ 0.125000 ]     --- [  0.00000 rad ]
|4>:     0.353553 +  0.000000 i  ==     ***                  [ 0.125000 ]     --- [  0.00000 rad ]
|5>:     0.353553 +  0.000000 i  ==     ***                  [ 0.125000 ]     --- [  0.00000 rad ]
|6>:     0.353553 +  0.000000 i  ==     ***                  [ 0.125000 ]     --- [  0.00000 rad ]
|7>:     0.353553 +  0.000000 i  ==     ***                  [ 0.125000 ]     --- [  0.00000 rad ]
After measurement:
# wave function for qubits with ids (least to most significant): 0;1;2
|0>:     0.000000 +  0.000000 i  ==                          [ 0.000000 ]
|1>:     0.000000 +  0.000000 i  ==                          [ 0.000000 ]
|2>:     0.000000 +  0.000000 i  ==                          [ 0.000000 ]
|3>:     1.000000 +  0.000000 i  ==     ******************** [ 1.000000 ]     --- [  0.00000 rad ]
|4>:     0.000000 +  0.000000 i  ==                          [ 0.000000 ]
|5>:     0.000000 +  0.000000 i  ==                          [ 0.000000 ]
|6>:     0.000000 +  0.000000 i  ==                          [ 0.000000 ]
|7>:     0.000000 +  0.000000 i  ==                          [ 0.000000 ]

Post-QFT measurement results [qubit0, qubit1, qubit2]: 
[One,One,Zero]

Questo output illustra alcuni aspetti diversi:

  1. Confrontando il risultato restituito con la pre-misurazione DumpMachine, è chiaro che non viene illustrata la sovrapposizione post-QFT agli stati di base. Una misurazione restituisce solo uno stato di base, con una probabilità determinata dall'ampiezza di tale stato nella funzione d'onda del sistema.
  2. Dalla post-misurazione DumpMachine si può notare che la misurazione cambia lo stato stesso, proiettandolo dalla sovrapposizione iniziale agli stati di base al singolo stato di base corrispondente al valore misurato.

Ripetendo questa operazione più volte, si noterà che le statistiche dei risultati inizieranno a illustrare la sovrapposizione ugualmente ponderata dello stato post-QFT, che dà origine a un risultato casuale per ogni esecuzione. Tuttavia, oltre a essere inefficiente e comunque imperfetta, questa operazione riprodurrebbe solo le ampiezze relative degli stati di base, non le fasi relative tra di loro. Quest'ultimo non è un problema in questo esempio, ma si noterebbe che vengono visualizzate fasi relative se per il QFT venisse fornito un input più complesso rispetto a $\ket{000}$.

Misurazioni parziali

Per esplorare il modo in cui la misurazione solo di alcuni qubit del registro può influire sullo stato del sistema, provare ad aggiungere quanto segue all'interno del ciclo for, dopo la linea di misurazione:

                let iString = IntAsString(i);
                Message("After measurement of qubit " + iString + ":");
                DumpMachine();

Si noti che per accedere alla funzione IntAsString, è necessario aggiungere un'istruzione open aggiuntiva:

    open Microsoft.Quantum.Convert;

Nell'output risultante viene visualizzata la proiezione graduale nei sottospazi quando viene misurato ogni qubit.

Usare le librerie Q#

Come accennato nell'introduzione, gran parte della potenza di Q# è basata sul fatto che consente di astrarre i problemi di gestione dei singoli qubit. In effetti, se si desidera sviluppare programmi quantistici applicabili e su larga scala, la preoccupazione che un'operazione H venga eseguita prima o dopo una determinata rotazione potrebbe solo rallentare l'utente.

Le librerie Q# contengono l'operazione QFT, che è possibile usare e applicare per qualsiasi numero di qubit. Per provare, definire una nuova operazione nel file Q# con lo stesso contenuto di Perform3QubitQFT, ma con tutti gli elementi dalla prima H a SWAP sostituiti con due semplici righe:

            let register = BigEndian(qs);    //from Microsoft.Quantum.Arithmetic
            QFT(register);                   //from Microsoft.Quantum.Canon

La prima riga crea un'espressione BigEndian della matrice allocata di qubit, qs, che è ciò che l'operazione QFT accetta come argomento. Questo corrisponde all'ordinamento dell'indice dei qubit nel registro.

Per accedere a queste operazioni, aggiungere istruzioni open per i rispettivi spazi dei nomi all'inizio del file Q#:

    open Microsoft.Quantum.Canon;
    open Microsoft.Quantum.Arithmetic;

Modificare ora il programma host in modo che chiami il nome della nuova operazione, ad esempio PerformIntrinsicQFT, ed eseguirlo di nuovo.

Per visualizzare il vantaggio reale dell'uso delle operazioni della libreria Q#, modificare il numero di qubit in un valore diverso da 3:

        mutable resultArray = [Zero, size = 4];

        use qs = Qubit[4];
        //...

È quindi possibile applicare il QFT appropriato per un determinato numero di qubit, senza doversi preoccupare della confusione di nuove operazioni H e rotazioni su ogni qubit.

Passaggi successivi

Continuare a esplorare altri algoritmi quantistici e tecniche:

  • L'esercitazione Implementare l'algoritmo di ricerca di Grover mostra come scrivere un programma Q# che usi l'algoritmo di ricerca di Grover per risolvere un problema di colorazione del grafo.
  • Esplorare l'entanglement con Q# mostra come scrivere un programma Q# che modifichi e misuri i qubit e mostra gli effetti della sovrapposizione e dell'entanglement.
  • Quantum Katas è una serie di esercitazioni ed esercizi di programmazione autogestiti basati su Jupyter Notebook per insegnare i concetti del calcolo quantistico e allo stesso tempo la programmazione Q#.