In my previous article on developing your own acoustic PHY, I showed you how to develop your own acoustic PHY in Groovy or Java. However, Groovy and Java are not well suited to writing complex mathematical algorithms. Wouldn’t it be nice if we could write the algorithms in Julia instead? In this article, we take the custom PHY we developed previously, and replace the signal processing methods with the Julia equivalents. The technique applies not just to PHY agents, and so this article should get you started on leveraging Julia in any UnetStack agents (or for that matter in any Java or Groovy code).
Java-Julia Bridge
Julia is a wonderful language to implement mathematical and signal processing algorithms. It solves the two-language problem by allowing you to write your algorithm in a nice high-level mathematically intuitive style, while generating performant native code that can be used in real-time systems. In the “Harnessing the power of Julia in UnetStack — Part I” article, we looked at how to access UnetStack functionality from Julia. In this article, we’ll do the reverse – we’ll explore how to use Julia code in UnetStack agents.
In order to access Julia’s mathematical prowess from Groovy or Java agents, we need a way to call Julia functions from the JVM. The Java-Julia Bridge (JaJuB) project provides exactly this functionality, and we’ll leverage it in this article.
Modulation and demodulation
Recall from my previous article on developing your own acoustic PHY that we developed a simple uncoded binary frequency-shift keying (BFSK) implementation in Groovy. While this implementation wasn’t difficult to do, Groovy or Java aren’t ideally suited for mathematical algorithms. So let’s see what the equivalent code would look like in Julia.
The Groovy modulation code we used was encapsulated within a single method bytes2signal()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private float[] bytes2signal(byte[] buf) {
float[] signal = new float[buf.length * 8 * SAMPLES_PER_SYMBOL * 2] // 8 bits/byte, 2 floats/sample
int p = 0
for (int i = 0; i < buf.length; i++) {
for (int j = 0; j < 8; j++) {
int bit = (buf[i] >> j) & 0x01
float f = bit == 1 ? -NFREQ : NFREQ
for (int k = 0; k < SAMPLES_PER_SYMBOL; k++) {
signal[p++] = (float)Math.cos(2 * Math.PI * f * k)
signal[p++] = (float)Math.sin(2 * Math.PI * f * k)
}
}
}
return signal
}
The equivalent Julia code is this:
1
2
3
4
5
6
7
8
9
10
11
12
13
function bytes2signal(buf)
signal = Array{ComplexF32}(undef, length(buf) * 8 * SAMPLES_PER_SYMBOL)
p = 1
for b in buf
for j in 0:7
bit = (b >> j) & 0x01
f = bit == 1 ? -NFREQ : NFREQ
signal[p:p+SAMPLES_PER_SYMBOL-1] .= cis.(2pi * f * (0:SAMPLES_PER_SYMBOL-1))
p += SAMPLES_PER_SYMBOL
end
end
return signal
end
Being able to use complex numbers directly in Julia is such a pleasure! While this code isn’t particularly different, as you start implemeting more complex algorithms, you’ll appreciate the different a lot more.
The Groovy demodulation code we used was in the method signal2bytes()
and a helper method abs2
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private byte[] signal2bytes(float[] signal, int start) {
int n = (int)(signal.length / (2 * SAMPLES_PER_SYMBOL * 8)) // number of bytes
def buf = new byte[n]
int p = start
for (int i = 0; i < buf.length; i++) {
for (int j = 0; j < 8; j++) {
double s0re = 0 // real path of matched filter for f0
double s0im = 0 // imaginary path of matched filter for f0
double s1re = 0 // real path of matched filter for f1
double s1im = 0 // imaginary path of matched filter for f0
for (int k = 0; k < SAMPLES_PER_SYMBOL; k++) {
float re = signal[p++]
float im = signal[p++]
float rclk = (float)Math.cos(2 * Math.PI * NFREQ * k)
float iclk = (float)Math.sin(2 * Math.PI * NFREQ * k)
s0re += re*rclk + im*iclk
s0im += im*rclk - re*iclk
s1re += re*rclk - im*iclk
s1im += im*rclk + re*iclk
}
if (abs2(s1re, s1im) > abs2(s0re, s0im))
buf[i] = (byte)(buf[i] | (0x01 << j))
}
}
return buf
}
private double abs2(double re, double im) {
return re*re + im*im
}
In Julia, we don’t need the helper method, and the complex number calculations become simpler:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function signal2bytes(signal)
n = length(signal) ÷ (SAMPLES_PER_SYMBOL * 8)
buf = zeros(Int8, n)
p = 1
for i in 1:length(buf)
for j in 0:7
s = @view signal[p:p+SAMPLES_PER_SYMBOL-1]
p += SAMPLES_PER_SYMBOL
x = cis.(2pi * NFREQ .* (0:SAMPLES_PER_SYMBOL-1))
s0 = sum(s .* conj.(x))
s1 = sum(s .* x)
if abs(s1) > abs(s0)
buf[i] = buf[i] | (0x01 << j)
end
end
end
return buf
end
Using JaJuB from our PHY agent
In order to run the Julia code from our PHY agent, we need to pass it to JaJuB as a string. For a complex piece of code, we might store it in a .jl
file and load it as a resource from Groovy, but since our code is simple, we’ll just insert it in our Groovy source code directly. This also enables us to directly insert constants in our Groovy code (e.g. SAMPLES_PER_SYMBOL
and NFREQ
) into the Julia code using Groovy’s string interpolation syntax (${...}
), rather than have to pass them as arguments to the function.
The modulation method now looks like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private final String julia_bytes2signal = """
function bytes2signal(buf)
signal = Array{ComplexF32}(undef, length(buf) * 8 * ${SAMPLES_PER_SYMBOL})
p = 1
for b in buf
for j in 0:7
bit = (b >> j) & 0x01
f = bit == 1 ? -${NFREQ} : ${NFREQ}
signal[p:p+${SAMPLES_PER_SYMBOL-1}] .= cis.(2pi * f * (0:${SAMPLES_PER_SYMBOL-1}))
p += ${SAMPLES_PER_SYMBOL}
end
end
return signal
end
"""
private float[] bytes2signal(byte[] buf) {
def arg = new ByteArray(data: buf, dims: [buf.length] as int[])
def rv = (FloatArray)julia.call("bytes2signal", arg)
return rv?.data
}
The original bytes2signal()
method in Groovy is replaced by a julia.call()
to the Julia implementation of the function bytes2signal()
defined in julia_bytes2signal
. To pass the byte array to Julia, we need to wrap the byte[]
in a ByteArray
instance provided by JaJuB. The return value from the function is a complex float array, which we get as a FloatArray
instance. The data
field of the FloatArray
object is simply the float[]
that we return from the Groovy method.
Similarly, we convert the demodulation function to also use the Julia code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private final String julia_signal2bytes = """
function signal2bytes(signal)
n = length(signal) ÷ (${SAMPLES_PER_SYMBOL} * 8)
buf = zeros(Int8, n)
p = 1
for i in 1:length(buf)
for j in 0:7
s = @view signal[p:p+${SAMPLES_PER_SYMBOL-1}]
p += ${SAMPLES_PER_SYMBOL}
x = cis.(2pi * ${NFREQ} .* (0:${SAMPLES_PER_SYMBOL-1}))
s0 = sum(s .* conj.(x))
s1 = sum(s .* x)
if abs(s1) > abs(s0)
buf[i] = buf[i] | (0x01 << j)
end
end
end
return buf
end
"""
private byte[] signal2bytes(float[] signal, int start) {
def arg = new FloatArray(
data: signal[start..-1] as float[],
dims: [(signal.length-start).intdiv(2)] as int[],
isComplex: true
)
def rv = (ByteArray)julia.call('signal2bytes', arg)
return rv?.data
}
Here we pass in a FloatArray
representing the complex baseband signal to the Julia function, and get back a ByteArray
with the demodulated data.
Before we can use the julia.call()
functionality from JaJuB, we need to initialize Julia. We do this by adding an attribute to our class (after importing org.arl.jajub.*
):
1
private JuliaBridge julia = new JuliaBridge()
and loading the two functions at startup (code inserted in our setup()
method):
1
2
3
julia.open()
julia.exec(julia_bytes2signal)
julia.exec(julia_signal2bytes)
We also add a shutdown()
method to our agent to cleanly shutdown Julia when the agent terminates:
1
2
3
void shutdown() {
julia.close()
}
Thats it!
The entire modified agent source code can be found in the unet-contrib repository.
Testing our custom Julia PHY
Now that we’ve the modified Julia-based phy2
agent, it is time to try it out on Unet audio. Copy the MyJuliaPhy.groovy
file to the classes
folder in Unet audio. Download JaJuB jar and put it in the jars
folder.
If you don’t already have Julia installed, download and install it on your computer and ensure it’s in your PATH.
Then on the shell:
1
2
3
4
5
> phy.fullduplex = true
true
> container.add 'phy2', new MyJuliaPhy()
phy2
> subscribe phy2
We turn on fullduplex
so that we can transmit and receive on the same device. We subscribe to the phy2
agent’s topic so that we see the RxFrameNtf
when data is received.
TIP
Writing agents in Groovy in the
classes
folder of UnetStack is often convenient since Groovy can load the class directly from source, without needing explicit compilation. However, if there are errors in the code, Groovy’s classloader sometimes gives a cryptic “BUG! exception in phase 'semantic analysis' in source unit
” error message. If you encounter this, usegroovyc
to get a clearer error report:
$ groovyc -cp lib/unet-framework-3.2.0.jar:lib/fjage-1.8.0.jar:lib/unet-yoda-3.2.0.jar:jars/jajub-0.1.0.jar classes/MyJuliaPhy.groovy
and remember to delete off the resultant
MyJuliaPhy.class
file to avoid a stale class file being used later accidentally.
To test the agent, we make a transmission:
1
2
3
4
> phy2 << new TxFrameReq(data: [1,2,3])
AGREE
phy2 >> TxFrameNtf:INFORM[txTime:19013099]
phy2 >> RxFrameNtf:INFORM[type:CONTROL from:1 rxTime:19039936 rssi:-49.1 (3 bytes)]
You should have been able to hear the transmission on your speaker. After a short delay, you’d see the reception (RxFrameNtf
). We check the contents to ensure that we got the correct data back:
1
2
> ntf.data
[1, 2, 3]
If the frame had any errors, you’d have gotten a BadFrameNtf
. In that case, you may want to try adjusting your computer’s volume setting or transmit power (plvl
command), and try again.
Now that we can transmit and receive correctly, we can enable the rest of the network stack to use our new Julia PHY:
1
2
3
4
5
6
> uwlink.phy = 'phy2'
phy2
> mac.phy = 'phy2'
phy2
> ranging.phy = 'phy2'
phy2
We can send a text message via UnetStack’s remote agent:
1
2
3
4
> tell 0, 'hi'
AGREE
phy2 >> RxFrameNtf:INFORM[type:DATA from:1 protocol:3 rxTime:96861353 rssi:-48.7 (3 bytes)]
[1]: hi
This resulting datagram goes down the layers of the stack, passed through our new PHY to yield an acoustic signal, gets received by the PHY again to be converted to a datagram, which then goes back up the stack all the way to the remote agent who sends it to the shell for display!
TIP
With Unet audio, Julia calls from Java work seamlessly. However, if you’re running on a real modem, chances are that the modem’s JVM sandbox won’t let you install and run non-JVM code directly. If you have a coprocessor on your modem, you can install Julia on the coprocessor and run the
phy2
agent in a fjåge slave container. Alternatively you can run thephy2
agent even on your laptop, as long as the laptop is connected over Ethernet to thr modem. To start a slave container, just install Unet audio on the coprocessor/laptop, and start Unet withbin/unet sh <ipaddr> 1100
where<ipaddr>
is replaced by the IP address of your modem, and port 1100 is the API port set on the modem.
Conclusion
Developing complex algorithms in Julia is much easier than in Java or Groovy. By leveraging Julia from UnetStack agents, we bring the power of Julia to UnetStack. The custom Julia-based PHY implementation developed in this article demonstrates how easy it is to combine Groovy and Julia code seamlessly.
I am excited about developing cool new agents in UnetStack using Julia, and I hope you are too!