From www.AA6E.net
Jump to: navigation, search

PSKmeter.py

Pskmeter020-1.gif

See Centos 5 / Fedora 9 notes, below - 11/2009.

PSKmeter.py is a display and analysis program for the PSKmeter station accessory. It is written in Python under Linux, but it should be portable to Windows or MacOS systems. It uses the Tkinter package for window management and display. If you wish to use PSKmeter.py, you will have to obtain the pyserial package for serial port I/O.

Below is the code for the early (non-Pulse) pskmeter025.py and pskmeter026.py. Current (2012) versions of Linux typically use PulseAudio, which require some changes in the code. David Ranch, KI6ZHD, has provided the following "pskmeter026.py" version, for this new environment:

#!/usr/bin/python
# This file named pskmeter026.py
#
# PSKmeter.py communications & analysis for the PSKmeter
# See http://www.ssiserver.com/info/pskmeter/
#
# Copyright (c) 2004 Martin S. Ewing AA6E
# Permission granted to copy and adapt for amateur, non-commercial use.
# Developed with Python v. 2.2.3 on Fedora Linux FC1
# Requires pyserial package (pyserial.sourceforge.net)
# Thanks to WA5ZNU for pskmeter.c, which helped get this software going.
# Updates to support PulseAudio from KI6ZHD
#
import string, serial, time, sys, math, os
# Want to use ossaudiodev, but not available until Python 2.3
from Tkinter import *
#
# This is the serial port for communication with the PSKmeter
DEVICE="/dev/ham.pskmtr"
# Set the audio card mixer to work with 
#  for newer non-OSS systems, try 'modprobe snd-mixer-oss'
MIXER="/dev/mixer"
#
IDENT="PSKmeter.py 0.26  www.aa6e.net/aa6e 1/2012"
IDENT_SHORT="PSKmeter.py 0.26 AA6E 2012"
#
#
MS = 934 		# msec update interval
val = 64 * [  0.0 ]
rects = 64 * [ 0 ]	# rectangle items for data display
satmark = 0		# saturation marker
tcanvas = 0		# canvas for text
txtitm  = 0		# ADC text message
adcwin  = 0		# Window for ADC text
txtitem = 0
textRun = True		# Run the text dump (or freeze)
sndMixer  = 0		# Mixer mixer object
pcm_level = 0		# PCM output level
pcm_level_last = -1	# last level commanded by us
statusitem = 0		# status message
arcitem = 0		# Busy bee indicator widget
arcval = 0.0		# current value
trunitm = 0		# run/stop for text dump
#
# This way of interacting with the audio level settings is moderately 
# expensive (launching a new process), but it's the easiest route
# until we have Python 2.3 and ossaudiodevice support.
#
def getLevel() :	 # Get aumix PCM setting (Linux specific!)
    #find your correct ALSA device with: cat /proc/asound/cards
    command = "/usr/bin/amixer --c 1 cget iface=MIXER,name='PCM Playback Volume',numid=2 | grep ' : values'"
    pp = os.popen(command,"r")
    response = pp.read()   # "pcm xxx,  yyy,"
    pp.close()
    k = response.find(",")  
    value = int(response[11:k])
    #PulseAudio uses a valid rang of 0-128
    value = max(0, min(128, value) )
    return value
#
def setLevel(v) :	 # Set sound level via aumix (Linux specific!)
    global pcm_level_last
    #PulseAudio uses a valid rang of 0-128
    v = max(0, min(128, v) )
    #find your correct ALSA device with: cat /proc/asound/cards
    command = "/usr/bin/amixer -q --c 1 set PCM "+str(v)+"\n"
    pp = os.popen(command, "r")
    pp.close()
    return None
#
def openMixer() :	# "Open" the mixer.  Not much to do until ossaudio.
    global pcm_level
    pcm_level = getLevel()
    print "Starting with output audio level = ", pcm_level
    return None 
#
def updateMixer() :
    global out_scale, pcm_level, pcm_level_last
    pcm_level = out_scale.get()
    if pcm_level <> pcm_level_last :	# Has our control value changed?
       setLevel(pcm_level)		# If so, take it and be done
       pcm_level_last = pcm_level
       return None
    pcm_level_os = getLevel()		# Allow user to set some other way
    if pcm_level <> pcm_level_os :	# Has the OS value changed somehow?
       out_scale.set(pcm_level_os)	# Set our control to his value.
       pcm_level_last = pcm_level_os
    return None
#
def calibrate(uncaldata):
    SAT_THRESHOLD = 180		# When are we losing at high signal?
    ABS_MAX_LEVEL = 188		# When we stopped measuring!
    # ADC to RF volts conversion ... 189 values 0..188 ADC possibilities
    # Note: this curve applies to a particular unit.  Your mileage may vary.
    # 3/7/04 mse
    CAL = [ \
     0,  7,  8, 10, 11, 13, 14, 16, 18, 19, 21, 23, 24, 26, \
    27, 29, 30, 32, 33, 35, 37, 38, 40, 41, 43, 44, 46, 47, \
    49, 50, 52, 53, 55, 56, 58, 59, 61, 63, 64, 66, 67, 69, \
    71, 72, 73, 75, 76, 78, 79, 81, 82, 83, 85, 86, 88, 89, \
     91,  92,  94,  95,  97,  98, 100, 101, 103, 104, 106, 107, 109, 110, \
    112, 113, 115, 116, 118, 119, 120, 121, 123, 124, 125, 127, 128, 129, \
    130, 132, 133, 134, 136, 137, 138, 140, 141, 143, 144, 146, 147, 149, \
    150, 152, 153, 155, 156, 158, 160, 162, 164, 166, 168, 170, 172, 175, \
    177, 179, 181, 183, 185, 187, 190, 191, 193, 194, 196, 197, 199, 200, \
    202, 203, 205, 206, 208, 210, 212, 214, 216, 218, 220, 222, 224, 226, \
    228, 230, 232, 235, 238, 241, 243, 246, 249, 252, 255, 257, 260, 262, \
    265, 267, 270, 272, 275, 278, 281, 285, 288, 291, 295, 298, 301, 304, \
    307, 310, 315, 320, 325, 330, 335, 340, 345, 348, 352, 356, 360, 363, \
    366, 370, 372, 375, 377, 380, 390 ]
    #
    caldata =  64 * [ 0 ]
    for i in range(64) :
        v = ord( uncaldata[i] )
        v = min(v, ABS_MAX_LEVEL)	# total saturation?
        caldata[i] = CAL[v]
    return caldata, v >= SAT_THRESHOLD	# Calibrated data, saturation flg
# 
def analyze(data):
    global txtitem, canvas
    MAX_AMP = 380.0			# for our unit only?
    p = 64 * [ 0.0 ]
    vmax = max(data)
    amp =  "%0.2f"%(vmax/MAX_AMP)
    if vmax > 0 : 
        for i in range(64) : val[i] = float(data[i]) / vmax
    for frq in range(16) :
       u = 0; v = 0
       for i in range(64) :
           u += val[i] * math.cos( frq * (2*math.pi/64) * i )
           v += val[i] * math.sin( frq * (2*math.pi/64) * i )
       p[frq] = u**2 + v**2
    tpwr = 0
    for frq in range(1,16): tpwr += p[frq]	# Ignore D.C.
    hpwr = tpwr - p[1]
    if tpwr > 0 :
        imd = 10 * math.log10(hpwr/tpwr)
    else : imd = -99
    if txtitem == 0 :
         txtitem = canvas.create_text(100,155, \
             anchor=CENTER, \
             text="IMD = %3.1f dB, Amp = "%(imd)+amp )
    else :
         canvas.itemconfigure(txtitem, \
             text="IMD = %3.1f dB, Amp = "%(imd)+amp )
    return None
#
# Check what kind of mode we are in
#     0 - zero power (Tx off)
#     1 - power, but no zeroes (CW signal, most likely)
#     2 - one zero (sending data)
#     3 - two zeroes (data or idling)
# Returns d_mode (above) and list of two zero positions
#
def modeCheck(d):	# d is calibrated integer list
    Z_THRESH = 0.2	# defines relative value for zero location
    s = 64 * [ False ]	# threshold array
    list = [0,0]
    vmax = max(d)
    if vmax < 10: return 0, [0.0, 0.0]	# mode 0 (no power)
    # How many zeroes can we find?
    nz = 0
    for i in range(64):
        s[i] = ( float(d[i]) / vmax ) < Z_THRESH
    for i in range(64):			# Treat as circular buffer
        if (not s[i-1]) and s[i]: nz += 1
    if nz == 0: return 1, [0.0, 0.0]		# mode 1 (power, no zeroes)
    # find first zero
    x = 0; nx = 0; j = 0
    if s[0] : j -= 8		# we have a zero at phase 0, offset
    for i in range(64):
        if s[j] :
           nx += 1; x += j
        elif s[j-1] and (nx>0) : break	# have finished the zero
        j += 1
    xz1 = float(x) / nx
    xz1 = xz1 % 64			# in case the center is < 0
    if nz == 1: return 2, [xz1, 0.0]	# mode 2 (one zero)
    # find second zero
    x = 0; nx = 0; j = (int( xz1 )+8) % 64
    for i in range(64):
        if s[j] :
           nx += 1; x += j
        elif s[j-1] and (nx>0): break
        j = (j+1) % 64
    xz2 = float(x) / nx
    xz2 = xz2 % 64
    if xz1 > xz2 : 			# ensure xz1 < xz2
       t = xz1; xz1 = xz2; xz2 = t
    return 3, [xz1, xz2]		# mode 3 (two zeroes)
#
# Finish off update functions
#
def finish_update():
    global canvas
    canvas.update_idletasks()
    canvas.pack()
    canvas.after(MS, update)	# Schedule ourselves again
    updateMixer()		# Check if level setting has changed.
    return None
#
# Dim an existing graph
#
def doDimGraph():
    global canvas, rects
    for i in range(64):
        if rects[i]<>0 :
           canvas.itemconfigure(rects[i],fill='#8080FF')
    return None
# Draw bar graph for data
#
def doGraph(v,flag):
    global canvas, rects, satmark
    vmax = max(0.01, max(v) )
    tpi = 2.0*math.pi
    x0 = 5.0; y0 = 90.0; dx = 3.0
    pd = float(dx * 64)
    for i in range(64) :
      x = x0 + i*dx
      dy = v[i]*50/vmax
      if rects[i] == 0 :		# first time through?
        rects[i] = canvas.create_rectangle(x,y0-dy,x+(dx-1),y0+dy, \
           fill='blue', outline='')
        canvas.create_line(x,y0-50*math.sin(tpi*float(x-x0)/pd), \
           x+dx,y0-50*math.sin(tpi*float(x-x0+dx)/pd),fill='red',width=2)
        canvas.create_line(x,y0+50*math.sin(tpi*float(x-x0)/pd), \
           x+dx,y0+50*math.sin(tpi*float(x-x0+dx)/pd),fill='red',width=2)
      else :
        canvas.coords(rects[i],x,y0-dy,x+(dx-1),y0+dy)
        canvas.itemconfigure(rects[i],fill='blue')
    # Draw saturation indicator (or not)
    if satmark == 0 :
        smctrx = 14; smctry = 20; smrad = 5
        satmark = canvas.create_oval(smctrx-smrad,smctry-smrad, \
           smctrx+smrad,smctry+smrad, \
           outline='black', fill='')
        canvas.create_text(smctrx+2,smctry+15,text="Over", \
           font=('Verdana','8'),fill="black")
    if flag:
        canvas.itemconfigure(satmark, fill='red')
    else :
        canvas.itemconfigure(satmark, fill='')
    return None
#
# Start the periodic (1 second) update cycle.
# This routine called by the main graphic "canvas" after specification
#
def update():
    global satmark, txtitm, tcanvas, adcwin, out_scale
    global statusitem, canvas, arcitem, arcval
    vrot = 64 * [ 0.0 ]
    zeroes = [0.0, 0.0]
    # Bump the busy bee indicator
    arcval -= 90.0
    canvas.itemconfigure(arcitem, start=arcval, extent=180.0)
    # Interrogate the PSKmeter
    ser.write("s")
    uncaldata = ser.read(64)
    if len(uncaldata) <> 64 :
        # We may have lost power or been disconnected.
        # In this case, signal user, but do nothing more.
        canvas.itemconfigure(statusitem, text="Disconnected", \
           fill='#B08080')
        finish_update()
        return None
    if (tcanvas <> 0) :			# Dump raw data
      if textRun :
        uncaldmp = ""
        for i in range(0,64,2) :
           uncaldmp = uncaldmp + \
             "%02d %03d %03d\n"%(i,ord(uncaldata[i]),ord(uncaldata[i+1]))
           tcanvas.itemconfigure(txtitm,text=uncaldmp)
      tcanvas.pack()
    data, isSat = calibrate (uncaldata)
    if statusitem == 0 :
         statusitem = canvas.create_text(100,25, \
             anchor=CENTER, \
             text="" )
    # Four possibilities: no data, data w no zero, one zero, or two zeroes
    d_mode, zeroes = modeCheck(data)
    if d_mode == 0:
         canvas.itemconfigure(statusitem, text="Low Signal", \
            fill='red')
         canvas.itemconfigure(txtitem, text="- - -")
         doGraph(64 * [0.0], isSat)
         finish_update()
         return None
    else :
         canvas.itemconfigure(statusitem, text="")
    if d_mode == 1:		# No zeroes -- CW?
         canvas.itemconfigure(statusitem, text="Holding", \
            fill='black')
         doDimGraph()
         finish_update()
         return None
    if d_mode == 2:		# One zero -- data/non-idle
         canvas.itemconfigure(statusitem, text="Holding", \
            fill='black')
         doDimGraph()
         finish_update()
         return None
    # mode 3 - that's normal idle mode.
    # De-rectify signal
    for i in range(zeroes[0],zeroes[1]): data[i] = -data[i]
    for i in range(64) :		# rotate data
      vrot[i] = data[ (i+32+int(zeroes[0]))%64 ]
    data = vrot
    canvas.itemconfigure(statusitem, text="Running", fill='#008000')
    # Make bargraph
    doGraph(data, isSat)
    analyze(data)			# Do the math analysis.
    finish_update()
    return None
#
# Launch a child text window for diagnostic ADC dumps
#
def doText() :
    global txtitm, adcwin, root, tcanvas, trunning, trunitm
    if adcwin == 0:
        textRun = True
        adcwin = Toplevel(root)
        adcwin.title('Diagnostic')
        tcanvas = Canvas(adcwin, width=90, height=480)
        trunitm = tcanvas.create_text(45,470,text="running", \
           fill='blue',anchor=CENTER,font=('Verdana','8'))
        txtitm = tcanvas.create_text(10,10,anchor=NW,text="")
        tcanvas.pack()
        mfreeze= Button(adcwin, font=('Verdana',8), width=5, \
             text="Run/Stop", command=freezeText).pack(side=LEFT)
        mquitb = Button(adcwin, font=('Verdana',8), width=5, \
             text="Dismiss", command=closeText).pack(side=RIGHT)
        adcwin.protocol("WM_DELETE_WINDOW", closeText)
    return None
#
# Freeze or unfreeze the text updates
#
def freezeText() :
    global textRun, tcanvas, trunitm
    # It would be nice to change the button label, but Tkinter
    # doesn't make it simple.
    textRun = not textRun
    if textRun: info = "running"
    else : info = "stopped"
    tcanvas.itemconfigure(trunitm,text=info)
    return None
#
# Closer - shut down the text window
#
def closeText() :
    global adcwin, tcanvas, txtitm, trunitm
    tcanvas.destroy()
    adcwin.destroy()
    adcwin = 0
    tcanvas = 0
    txtitm  = 0
    trunitm = 0
    return None
#
# Main entry point.
#
print IDENT			# Print our own ID
root = Tk()
root.title('PSK31 monitor')
canvas = Canvas(root, width=200, height=170)
ser = serial.Serial(DEVICE,baudrate=19200,rtscts=0,timeout=0.2)
ser.flushInput()
ser.flushOutput()
# Get, check, and print the PSKmeter's ID
ser.write("v")
ver = ser.read(255)
if len(ver) == 0 :
   print "No responsei from PSKmeter.  Check power & cabling."
#   sys.exit()
# Note: version = 2 lines, terminating in cr/lf.  Allow timeout.
else : print ver[:-2]
canvas.create_text(100,6,text=IDENT_SHORT,fill="#808080", \
  font=('Verdana','8'),anchor=CENTER)
arcx=185; arcy=20; arcrad=5
canvas.create_oval(arcx-arcrad,arcy-arcrad,arcx+arcrad,arcy+arcrad)
arcitem = canvas.create_arc(arcx-arcrad,arcy-arcrad,\
   arcx+arcrad,arcy+arcrad,fill='green')
canvas.after(1, update)		# schedule immediate update
canvas.pack()
openMixer()			# sets initial pcm_level
out_scale = Scale(root, label="Soundcard PCM Output", orient=HORIZONTAL, \
  #PulseAudio uses a valid rang of 0-128
  troughcolor="#C0C0E0",fg="Blue", bg="#B0B0B0", length=220, to=128, \
  tickinterval=20)
out_scale.set(pcm_level)
out_scale.pack()
adcb = Button(root, text="View Raw", command=doText).pack(side=LEFT)
quitb = Button(root, text="Quit", command=root.quit).pack(side=RIGHT)
root.mainloop()			# Loop forever, responding to events
ser.flushInput()		# until the "quit" comes along.
ser.close()

The following is the earlier v025 version.

#!/usr/bin/python
#
# PSKmeter.py communications & analysis for the PSKmeter
# See http://www.ssiserver.com/info/pskmeter/
# Copyright (c) 2004 Martin S. Ewing AA6E
# Permission granted to copy and adapt for amateur, non-commercial use.
# Developed with Python v. 2.2.3 on Fedora Linux FC1
# Requires pyserial package (pyserial.sourceforge.net)
# Thanks to WA5ZNU for pskmeter.c, which helped get this software going.
#
import string, serial, time, sys, math, os
# Want to use ossaudiodev, but not available until Python 2.3
from Tkinter import *
#
# This is the serial port for communication with the PSKmeter
#DEVICE="/dev/ttyS0"
DEVICE="/dev/ham.pskmtr"
# Set the audio card mixer to work with
#MIXER="/dev/mixer"
MIXER="/dev/mixer1"
#
IDENT="PSKmeter.py 0.25  www.aa6e.net/aa6e 4/2004"
IDENT_SHORT="PSKmeter.py 0.25 AA6E 2004"
#
#
MS = 934 		# msec update interval
val = 64 * [  0.0 ]
rects = 64 * [ 0 ]	# rectangle items for data display
satmark = 0		# saturation marker
tcanvas = 0		# canvas for text
txtitm  = 0		# ADC text message
adcwin  = 0		# Window for ADC text
txtitem = 0
textRun = True		# Run the text dump (or freeze)
sndMixer  = 0		# Mixer mixer object
pcm_level = 0		# PCM output level
pcm_level_last = -1	# last level commanded by us
statusitem = 0		# status message
arcitem = 0		# Busy bee indicator widget
arcval = 0.0		# current value
trunitm = 0		# run/stop for text dump
#
# This way of interacting with the audio level settings is moderately 
# expensive (launching a new process), but it's the easiest route
# until we have Python 2.3 and ossaudiodevice support.
#
def getLevel() :	 # Get aumix PCM setting (Linux specific!)
    command = "/usr/bin/aumix -d "+MIXER+" -wq"
    pp = os.popen(command,"r")
    response = pp.read()   # "pcm xxx,  yyy,"
    pp.close()
    k = response.find(",")  
    value = int(response[4:k])
    value = max(0, min(100, value) )
    return value
#
def setLevel(v) :	 # Set sound level via aumix (Linux specific!)
    global pcm_level_last
    v = max(0, min(100, v) )
    command = "/usr/bin/aumix -d "+MIXER+" -w"+str(v)+"\n"
    pp = os.popen(command, "r")
    pp.close()
    return None
#
def openMixer() :	# "Open" the mixer.  Not much to do until ossaudio.
    global pcm_level
    pcm_level = getLevel()
    print "Starting with output audio level = ", pcm_level
    return None 
#
def updateMixer() :
    global out_scale, pcm_level, pcm_level_last
    pcm_level = out_scale.get()
    if pcm_level <> pcm_level_last :	# Has our control value changed?
       setLevel(pcm_level)		# If so, take it and be done
       pcm_level_last = pcm_level
       return None
    pcm_level_os = getLevel()		# Allow user to set some other way
    if pcm_level <> pcm_level_os :	# Has the OS value changed somehow?
       out_scale.set(pcm_level_os)	# Set our control to his value.
       pcm_level_last = pcm_level_os
    return None
#
def calibrate(uncaldata):
    SAT_THRESHOLD = 180		# When are we losing at high signal?
    ABS_MAX_LEVEL = 188		# When we stopped measuring!
    # ADC to RF volts conversion ... 189 values 0..188 ADC possibilities
    # Note: this curve applies to a particular unit.  Your mileage may vary.
    # 3/7/04 mse
    CAL = [ \
     0,  7,  8, 10, 11, 13, 14, 16, 18, 19, 21, 23, 24, 26, \
    27, 29, 30, 32, 33, 35, 37, 38, 40, 41, 43, 44, 46, 47, \
    49, 50, 52, 53, 55, 56, 58, 59, 61, 63, 64, 66, 67, 69, \
    71, 72, 73, 75, 76, 78, 79, 81, 82, 83, 85, 86, 88, 89, \
     91,  92,  94,  95,  97,  98, 100, 101, 103, 104, 106, 107, 109, 110, \
    112, 113, 115, 116, 118, 119, 120, 121, 123, 124, 125, 127, 128, 129, \
    130, 132, 133, 134, 136, 137, 138, 140, 141, 143, 144, 146, 147, 149, \
    150, 152, 153, 155, 156, 158, 160, 162, 164, 166, 168, 170, 172, 175, \
    177, 179, 181, 183, 185, 187, 190, 191, 193, 194, 196, 197, 199, 200, \
    202, 203, 205, 206, 208, 210, 212, 214, 216, 218, 220, 222, 224, 226, \
    228, 230, 232, 235, 238, 241, 243, 246, 249, 252, 255, 257, 260, 262, \
    265, 267, 270, 272, 275, 278, 281, 285, 288, 291, 295, 298, 301, 304, \
    307, 310, 315, 320, 325, 330, 335, 340, 345, 348, 352, 356, 360, 363, \
    366, 370, 372, 375, 377, 380, 390 ]
    #
    caldata =  64 * [ 0 ]
    for i in range(64) :
        v = ord( uncaldata[i] )
        v = min(v, ABS_MAX_LEVEL)	# total saturation?
        caldata[i] = CAL[v]
    return caldata, v >= SAT_THRESHOLD	# Calibrated data, saturation flg
# 
def analyze(data):
    global txtitem, canvas
    MAX_AMP = 380.0			# for our unit only?
    p = 64 * [ 0.0 ]
    vmax = max(data)
    amp =  "%0.2f"%(vmax/MAX_AMP)
    if vmax > 0 : 
        for i in range(64) : val[i] = float(data[i]) / vmax
    for frq in range(16) :
       u = 0; v = 0
       for i in range(64) :
           u += val[i] * math.cos( frq * (2*math.pi/64) * i )
           v += val[i] * math.sin( frq * (2*math.pi/64) * i )
       p[frq] = u**2 + v**2
    tpwr = 0
    for frq in range(1,16): tpwr += p[frq]	# Ignore D.C.
    hpwr = tpwr - p[1]
    if tpwr > 0 :
        imd = 10 * math.log10(hpwr/tpwr)
    else : imd = -99
    if txtitem == 0 :
         txtitem = canvas.create_text(100,155, \
             anchor=CENTER, \
             text="IMD = %3.1f dB, Amp = "%(imd)+amp )
    else :
         canvas.itemconfigure(txtitem, \
             text="IMD = %3.1f dB, Amp = "%(imd)+amp )
    return None
#
# Check what kind of mode we are in
#     0 - zero power (Tx off)
#     1 - power, but no zeroes (CW signal, most likely)
#     2 - one zero (sending data)
#     3 - two zeroes (data or idling)
# Returns d_mode (above) and list of two zero positions
#
def modeCheck(d):	# d is calibrated integer list
    Z_THRESH = 0.2	# defines relative value for zero location
    s = 64 * [ False ]	# threshold array
    list = [0,0]
    vmax = max(d)
    if vmax < 10: return 0, [0.0, 0.0]	# mode 0 (no power)
    # How many zeroes can we find?
    nz = 0
    for i in range(64):
        s[i] = ( float(d[i]) / vmax ) < Z_THRESH
    for i in range(64):			# Treat as circular buffer
        if (not s[i-1]) and s[i]: nz += 1
    if nz == 0: return 1, [0.0, 0.0]		# mode 1 (power, no zeroes)
    # find first zero
    x = 0; nx = 0; j = 0
    if s[0] : j -= 8		# we have a zero at phase 0, offset
    for i in range(64):
        if s[j] :
           nx += 1; x += j
        elif s[j-1] and (nx>0) : break	# have finished the zero
        j += 1
    xz1 = float(x) / nx
    xz1 = xz1 % 64			# in case the center is < 0
    if nz == 1: return 2, [xz1, 0.0]	# mode 2 (one zero)
    # find second zero
    x = 0; nx = 0; j = (int( xz1 )+8) % 64
    for i in range(64):
        if s[j] :
           nx += 1; x += j
        elif s[j-1] and (nx>0): break
        j = (j+1) % 64
    xz2 = float(x) / nx
    xz2 = xz2 % 64
    if xz1 > xz2 : 			# ensure xz1 < xz2
       t = xz1; xz1 = xz2; xz2 = t
    return 3, [xz1, xz2]		# mode 3 (two zeroes)
#
# Finish off update functions
#
def finish_update():
    global canvas
    canvas.update_idletasks()
    canvas.pack()
    canvas.after(MS, update)	# Schedule ourselves again
    updateMixer()		# Check if level setting has changed.
    return None
#
# Dim an existing graph
#
def doDimGraph():
    global canvas, rects
    for i in range(64):
        if rects[i]<>0 :
           canvas.itemconfigure(rects[i],fill='#8080FF')
    return None
# Draw bar graph for data
#
def doGraph(v,flag):
    global canvas, rects, satmark
    vmax = max(0.01, max(v) )
    tpi = 2.0*math.pi
    x0 = 5.0; y0 = 90.0; dx = 3.0
    pd = float(dx * 64)
    for i in range(64) :
      x = x0 + i*dx
      dy = v[i]*50/vmax
      if rects[i] == 0 :		# first time through?
        rects[i] = canvas.create_rectangle(x,y0-dy,x+(dx-1),y0+dy, \
           fill='blue', outline='')
        canvas.create_line(x,y0-50*math.sin(tpi*float(x-x0)/pd), \
           x+dx,y0-50*math.sin(tpi*float(x-x0+dx)/pd),fill='red',width=2)
        canvas.create_line(x,y0+50*math.sin(tpi*float(x-x0)/pd), \
           x+dx,y0+50*math.sin(tpi*float(x-x0+dx)/pd),fill='red',width=2)
      else :
        canvas.coords(rects[i],x,y0-dy,x+(dx-1),y0+dy)
        canvas.itemconfigure(rects[i],fill='blue')
    # Draw saturation indicator (or not)
    if satmark == 0 :
        smctrx = 14; smctry = 20; smrad = 5
        satmark = canvas.create_oval(smctrx-smrad,smctry-smrad, \
           smctrx+smrad,smctry+smrad, \
           outline='black', fill='')
        canvas.create_text(smctrx+2,smctry+15,text="Over", \
           font=('Verdana','8'),fill="black")
    if flag:
        canvas.itemconfigure(satmark, fill='red')
    else :
        canvas.itemconfigure(satmark, fill='')
    return None
#
# Start the periodic (1 second) update cycle.
# This routine called by the main graphic "canvas" after specification
#
def update():
    global satmark, txtitm, tcanvas, adcwin, out_scale
    global statusitem, canvas, arcitem, arcval
    vrot = 64 * [ 0.0 ]
    zeroes = [0.0, 0.0]
    # Bump the busy bee indicator
    arcval -= 90.0
    canvas.itemconfigure(arcitem, start=arcval, extent=180.0)
    # Interrogate the PSKmeter
    ser.write("s")
    uncaldata = ser.read(64)
    if len(uncaldata) <> 64 :
        # We may have lost power or been disconnected.
        # In this case, signal user, but do nothing more.
        canvas.itemconfigure(statusitem, text="Disconnected", \
           fill='#B08080')
        finish_update()
        return None
    if (tcanvas <> 0) :			# Dump raw data
      if textRun :
        uncaldmp = ""
        for i in range(0,64,2) :
           uncaldmp = uncaldmp + \
             "%02d %03d %03d\n"%(i,ord(uncaldata[i]),ord(uncaldata[i+1]))
           tcanvas.itemconfigure(txtitm,text=uncaldmp)
      tcanvas.pack()
    data, isSat = calibrate (uncaldata)
    if statusitem == 0 :
         statusitem = canvas.create_text(100,25, \
             anchor=CENTER, \
             text="" )
    # Four possibilities: no data, data w no zero, one zero, or two zeroes
    d_mode, zeroes = modeCheck(data)
    if d_mode == 0:
         canvas.itemconfigure(statusitem, text="Low Signal", \
            fill='red')
         canvas.itemconfigure(txtitem, text="- - -")
         doGraph(64 * [0.0], isSat)
         finish_update()
         return None
    else :
         canvas.itemconfigure(statusitem, text="")
    if d_mode == 1:		# No zeroes -- CW?
         canvas.itemconfigure(statusitem, text="Holding", \
            fill='black')
         doDimGraph()
         finish_update()
         return None
    if d_mode == 2:		# One zero -- data/non-idle
         canvas.itemconfigure(statusitem, text="Holding", \
            fill='black')
         doDimGraph()
         finish_update()
         return None
    # mode 3 - that's normal idle mode.
    # De-rectify signal
    for i in range(zeroes[0],zeroes[1]): data[i] = -data[i]
    for i in range(64) :		# rotate data
      vrot[i] = data[ (i+32+int(zeroes[0]))%64 ]
    data = vrot
    canvas.itemconfigure(statusitem, text="Running", fill='#008000')
    # Make bargraph
    doGraph(data, isSat)
    analyze(data)			# Do the math analysis.
    finish_update()
    return None
#
# Launch a child text window for diagnostic ADC dumps
#
def doText() :
    global txtitm, adcwin, root, tcanvas, trunning, trunitm
    if adcwin == 0:
        textRun = True
        adcwin = Toplevel(root)
        adcwin.title('Diagnostic')
        tcanvas = Canvas(adcwin, width=90, height=480)
        trunitm = tcanvas.create_text(45,470,text="running", \
           fill='blue',anchor=CENTER,font=('Verdana','8'))
        txtitm = tcanvas.create_text(10,10,anchor=NW,text="")
        tcanvas.pack()
        mfreeze= Button(adcwin, font=('Verdana',8), width=5, \
             text="Run/Stop", command=freezeText).pack(side=LEFT)
        mquitb = Button(adcwin, font=('Verdana',8), width=5, \
             text="Dismiss", command=closeText).pack(side=RIGHT)
        adcwin.protocol("WM_DELETE_WINDOW", closeText)
    return None
#
# Freeze or unfreeze the text updates
#
def freezeText() :
    global textRun, tcanvas, trunitm
    # It would be nice to change the button label, but Tkinter
    # doesn't make it simple.
    textRun = not textRun
    if textRun: info = "running"
    else : info = "stopped"
    tcanvas.itemconfigure(trunitm,text=info)
    return None
#
# Closer - shut down the text window
#
def closeText() :
    global adcwin, tcanvas, txtitm, trunitm
    tcanvas.destroy()
    adcwin.destroy()
    adcwin = 0
    tcanvas = 0
    txtitm  = 0
    trunitm = 0
    return None
#
# Main entry point.
#
print IDENT			# Print our own ID
root = Tk()
root.title('PSK31 monitor')
canvas = Canvas(root, width=200, height=170)
ser = serial.Serial(DEVICE,baudrate=19200,rtscts=0,timeout=0.2)
ser.flushInput()
ser.flushOutput()
# Get, check, and print the PSKmeter's ID
ser.write("v")
ver = ser.read(255)
if len(ver) == 0 :
   print "No responsei from PSKmeter.  Check power & cabling."
#   sys.exit()
# Note: version = 2 lines, terminating in cr/lf.  Allow timeout.
else : print ver[:-2]
canvas.create_text(100,6,text=IDENT_SHORT,fill="#808080", \
  font=('Verdana','8'),anchor=CENTER)
arcx=185; arcy=20; arcrad=5
canvas.create_oval(arcx-arcrad,arcy-arcrad,arcx+arcrad,arcy+arcrad)
arcitem = canvas.create_arc(arcx-arcrad,arcy-arcrad,\
   arcx+arcrad,arcy+arcrad,fill='green')
canvas.after(1, update)		# schedule immediate update
canvas.pack()
openMixer()			# sets initial pcm_level
out_scale = Scale(root, label="Soundcard PCM Output", orient=HORIZONTAL, \
  troughcolor="#C0C0E0",fg="Blue", bg="#B0B0B0", length=180, to=100, \
  tickinterval=20)
out_scale.set(pcm_level)
out_scale.pack()
adcb = Button(root, text="View Raw", command=doText).pack(side=LEFT)
quitb = Button(root, text="Quit", command=root.quit).pack(side=RIGHT)
root.mainloop()			# Loop forever, responding to events
ser.flushInput()		# until the "quit" comes along.
ser.close()

PSKmeter.py is copyrighted software. You may copy or adapt it freely for non-commercial applications. It may not be republished without permission.


Release Notes

Pskmeter020-2.gif
Features
  • Full-cycle IMD analysis using "derectified" data.
  • Improved zero detection capability
  • ADC data are now linearized against a measured calibration function.
  • Controls soundcard PCM level - manual, not automatic
  • Provides optional raw data dump
  • Overload indicator

Known issues and areas for development

  • There is still some jitter in the waveform sync. This is mainly an esthetic problem. A complete cure would mean going to a 128 or 256 point interpolated data array.
  • Some improvements to the IMD / modulation quality index are possible, by choosing other statistics, by averaging over cycles, etc.
  • The PSKmeter is a great tuning aid, but it has limited dynamic range and it's not very linear. As such, I don't advise relying on its "IMD" numbers too much.
  • The calibration curve, overload limit, etc. are based on my particular unit. Your mileage may vary.
  • This program was developed using Python 2.2.3 under Fedora Linux FC1.
  • Because Python 2.2.3 does not have direct support for audio mixer control, PSKmeter.py 0.20 uses a piped process running 'aumix'. With Python 2.3, direct OSS audio support is available.
  • The audio mixer interface is Linux-specific, but should be adaptable to other platforms.

Your comments and suggestions are welcome -- to aa6e ...at... arrl.net.

Updated: 3/14/2004, 11/18/2009; Wikified 7/6/2010

Centos5 / Fedora 9 Usage

(David Ranch, Nov., 2009)

Download the ported pskmeter.py app and the currently stable versionof
pyserial module at http://aa6e.net/software/psk/

As root,

- download and install the pyserial module (for version 2.4):
python setup.py install

- install Tkinter
yum install tkinter

- install aumix (no Yum package available)
download from http://freshmeat.net/projects/aumix/
#This is a nice mixer that gives you NUMBERS for levels instead of
just the usual arbitrary ticks:

tar xivf aumix-2.8.tar.bz2
cd aumix-2.8
./configure
make
sudo make install
ln -s /usr/local/bin/aumix /usr/bin/aumix

Note: I re-used the USB to serial interface I had connected to my Yaesu
FT-950's CAT interface. To make sure that fldigi wasn't going to
conflict, I disabled the HAMLIB interface.

Configuring pskmeter.py:

- the pskmeter.py program defaults to looking for it's controlling
serial interface at /dev/ham.pskmtr. To make it work for my setup, I
did the following as root:

ln -s /dev/ttyUSB0 /dev/ham.pskmtr

- Make sure you run "chmod 666 /dev/ttyUSB0" so that the pskmeter.py
program can access the serial port

- The pskmeter.py program defaults to an alternative mixer. Edit the
pskmeter.py program file and change the MIXER setting to:

MIXER="/dev/mixer"

- Follow the PSKmeter instructions in testing the PSKmeter's serial
port but if you run minicom at 19200-8N1 and turn it on, you should see
a plain english initialization string

Now, fire up fldigi (or whatever app your using to do PSK31):

- find a clear, open frequency
- set your rig to low power
- tune up to a low SWR
- now insert the pskmeter between the rig and the antenna tuner to protect
it from reflected power
- Start up the meter with:
python pskmeter025.py

- In Fldigi, there is the TUNE button in the upper right but that
transmits SINGLE tone. Not what you want. You want to run a PSK Tx by
typing in control-t

Note on changing your soundcard's volume settings:
- setting the PCM output too low only looses resolution but
doesn't distort the signal. Setting it too high creates distortion and
the tops of the pskmeter lobes start to drop in. That's bad.



> On Mon, Nov 16, 2009 at 12:47 AM, David Ranch wrote:
>>
>> Anyway, I appreciate your work on the port. I'm working on this with Centos 5.4 (I can send you the instructions if you'd like to post it) and it's responding but it's not graphing anything within the double lobes. If I view the RAW mode, the digits from 00 to 62 do increase though they are all the same number as I increase power. Running pskmeter on windows does graph things though all bars are the same height which seems to agree with why pskmeter.py shows though without any green bars.
>>
>> Thoughts?
>>
>> --Davi
>>
>


11/18/09
Hello Martin,

It turns out that the test signal I was sending was CW and not PSK.
Upon reflection, that makes perfect sense of why all the values would be the same!

To me, adding a FAQ entries to the original PSKmeter (and #3 on your
page) would be helpful:

1. If there isn't any signal waveform but under the text view, all
fields are equal and increase as you add more Tx power, you're probably
sending a CW test signal and NOT a PSK signal

2. PSKmeter for linux won't display any signal graphics until the
expected PSK waveform is detected. For example, PSKmeter for Linux
won't display a flat CW waveform

Btw, here are my notes on how to get PSKmeter for LInux installed and
working. Discovering and fixing some of the dependances might be
overwhelming for newbies. They are easy to do and the more we can do to get/keep HAMs on Linux, the better!