FieldGraph

This shows an simple example of an MVC based application, that also makes use of NSBezierPaths.

The application calculates the field pattern and RMS field of an antenna array with up to three elements.

Sources

CGraphController.py

import objc
import Cocoa
from PyObjCTools import AppHelper

from fieldMath import degToRad, radToDeg


#____________________________________________________________
class CGraphController(Cocoa.NSObject):
    graphModel = objc.IBOutlet()
    graphView = objc.IBOutlet()
    fieldNormalizeCheck = objc.IBOutlet()
    settingDrawer = objc.IBOutlet()
    fieldSlider0 = objc.IBOutlet()
    fieldSlider1 = objc.IBOutlet()
    fieldSlider2 = objc.IBOutlet()
    phaseSlider0 = objc.IBOutlet()
    phaseSlider1 = objc.IBOutlet()
    phaseSlider2 = objc.IBOutlet()
    spacingSlider = objc.IBOutlet()
    fieldDisplay0 = objc.IBOutlet()
    fieldDisplay1 = objc.IBOutlet()
    fieldDisplay2 = objc.IBOutlet()
    phaseDisplay0 = objc.IBOutlet()
    phaseDisplay1 = objc.IBOutlet()
    phaseDisplay2 = objc.IBOutlet()
    RMSGainDisplay = objc.IBOutlet()
    spacingDisplay = objc.IBOutlet()

#____________________________________________________________
# Update GUI display and control values

    def awakeFromNib(self):
        self.mapImage = Cocoa.NSImage.imageNamed_("Map")
        self.graphView.setMapImage(self.mapImage)
        self.drawGraph()

    def drawGraph(self):
        self.spacingDisplay.setFloatValue_(radToDeg(self.graphModel.getSpacing()))
        self.spacingSlider.setFloatValue_(radToDeg(self.graphModel.getSpacing()))
        self.fieldDisplay0.setFloatValue_(self.graphModel.getField(0))
        self.fieldDisplay1.setFloatValue_(self.graphModel.getField(1))
        self.fieldDisplay2.setFloatValue_(self.graphModel.getField(2))
        self.fieldSlider0.setFloatValue_(self.graphModel.getField(0))
        self.fieldSlider1.setFloatValue_(self.graphModel.getField(1))
        self.fieldSlider2.setFloatValue_(self.graphModel.getField(2))
        self.phaseDisplay0.setFloatValue_(radToDeg(self.graphModel.getPhase(0)))
        self.phaseDisplay1.setFloatValue_(radToDeg(self.graphModel.getPhase(1)))
        self.phaseDisplay2.setFloatValue_(radToDeg(self.graphModel.getPhase(2)))
        self.phaseSlider0.setFloatValue_(radToDeg(self.graphModel.getPhase(0)))
        self.phaseSlider1.setFloatValue_(radToDeg(self.graphModel.getPhase(1)))
        self.phaseSlider2.setFloatValue_(radToDeg(self.graphModel.getPhase(2)))

        totalField = self.graphModel.getField(0) + self.graphModel.getField(1) + self.graphModel.getField(2)

        RMSGain = self.graphModel.fieldGain()
        self.graphView.setGain(RMSGain, totalField)
        self.RMSGainDisplay.setFloatValue_(RMSGain*100.0)

        path, maxMag  = self.graphModel.getGraph()
        self.graphView.setPath(path, maxMag)


#____________________________________________________________
# Handle GUI values

    @objc.IBAction
    def fieldDisplay0_(self, sender):
        self.setNormalizedField(0, sender.floatValue())
        self.drawGraph()

    @objc.IBAction
    def fieldDisplay1_(self, sender):
        self.setNormalizedField(1, sender.floatValue())
        self.drawGraph()

    @objc.IBAction
    def fieldDisplay2_(self, sender):
        self.setNormalizedField(2, sender.floatValue())
        self.drawGraph()

    @objc.IBAction
    def fieldSlider0_(self, sender):
        self.setNormalizedField(0, sender.floatValue())
        self.drawGraph()

    @objc.IBAction
    def fieldSlider1_(self, sender):
        self.setNormalizedField(1, sender.floatValue())
        self.drawGraph()

    @objc.IBAction
    def fieldSlider2_(self, sender):
        self.setNormalizedField(2, sender.floatValue())
        self.drawGraph()

    def setNormalizedField(self, t, v):
        if self.fieldNormalizeCheck.intValue():
            f = [0, 0, 0]
            cft = 0
            for i in range(3):
                f[i] = self.graphModel.getField(i)
                cft += f[i]

            aft = cft - v
            if aft < 0.001:
                v = cft - 0.001
                aft = 0.001
            f[t] = v

            nft = 0
            for i in range(3):
                nft +=  f[i]
            r = aft / (nft - f[t])

            for i in range(3):
                self.graphModel.setField(i, f[i] * r)
            self.graphModel.setField(t, v)

        else:
            self.graphModel.setField(t, v)


    @objc.IBAction
    def phaseDisplay0_(self, sender):
        self.graphModel.setPhase(0, degToRad(sender.floatValue()))
        self.drawGraph()

    @objc.IBAction
    def phaseDisplay1_(self, sender):
        self.graphModel.setPhase(1, degToRad(sender.floatValue()))
        self.drawGraph()

    @objc.IBAction
    def phaseDisplay2_(self, sender):
        self.graphModel.setPhase(2, degToRad(sender.floatValue()))
        self.drawGraph()

    @objc.IBAction
    def phaseSlider0_(self, sender):
        self.graphModel.setPhase(0, degToRad(sender.floatValue()))
        self.drawGraph()

    @objc.IBAction
    def phaseSlider1_(self, sender):
        self.graphModel.setPhase(1, degToRad(sender.floatValue()))
        self.drawGraph()

    @objc.IBAction
    def phaseSlider2_(self, sender):
        self.graphModel.setPhase(2, degToRad(sender.floatValue()))
        self.drawGraph()

    @objc.IBAction
    def spacingDisplay_(self, sender):
        self.graphModel.setSpacing(degToRad(sender.floatValue()))
        self.drawGraph()

    @objc.IBAction
    def spacingSlider_(self, sender):
        self.graphModel.setSpacing(degToRad(sender.floatValue()))
        self.drawGraph()

    @objc.IBAction
    def settingDrawerButton_(self, sender):
        self.settingDrawer.toggle_(self)

CGraphModel.py

import objc
from Foundation import NSObject
from AppKit import NSBezierPath

from fieldMath import bessel, degToRad, polarToRect
from math import cos, sin, sqrt, hypot, pi

#____________________________________________________________
class CGraphModel(NSObject):

    def init(self):
        self.field = [1.0, 1.12, 0.567]
        self.phase = [degToRad(0), degToRad(152.6), degToRad(312.9-360)]
        self.RMSGain = 0
        self.spacing = degToRad(90)
        return self

    def getGraph(self):
        path = NSBezierPath.bezierPath()

        maxMag = 0
        mag = self.fieldValue(0)

        maxMag = max(maxMag, mag)
        path.moveToPoint_(polarToRect((mag, 0)))
        for deg in range(1, 359, 1):
            r = (deg/180.0)*pi
            mag = self.fieldValue(r)
            maxMag = max(maxMag, mag)
            path.lineToPoint_(polarToRect((mag, r)))
        path.closePath()

        return path, maxMag;

    def fieldGain(self):
        gain = 0
        Et = self.field[0] + self.field[1] + self.field[2]
        if Et:          # Don't want to divide by zero in the pathological case
            spacing = [0, self.spacing, 2*self.spacing]

            # This could easily be optimized--but this is just anexample :-)
            for i in range(3):
                for j in range(3):
                    gain += self.field[i]*self.field[j] * cos(self.phase[j]-self.phase[i]) * bessel(spacing[j]-spacing[i])
            gain = sqrt(gain) / Et

        self.RMSGain = gain
        return gain

    def fieldValue(self, a):
        # The intermedate values are used to more closely match standard field equations nomenclature
        E0 = self.field[0]
        E1 = self.field[1]
        E2 = self.field[2]
        B0 = self.phase[0]
        B1 = self.phase[1] + self.spacing * cos(a)
        B2 = self.phase[2] + 2 * self.spacing * cos(a)

        phix = sin(B0) * E0  + sin(B1) * E1 + sin(B2) * E2
        phiy = cos(B0) * E0 + cos(B1) * E1 + cos(B2) * E2
        mag = hypot(phix, phiy)

        return mag


    def setField(self, tower, field):
        self.field[tower] = field

    def getField(self, tower):
        return self.field[tower]

    def setPhase(self, tower, phase):
        self.phase[tower] = phase

    def getPhase(self, tower):
        return self.phase[tower]

    def setSpacing(self, spacing):
        self.spacing = spacing

    def getSpacing(self):
        return self.spacing

CGraphView.py

import objc
from objc import super
import Cocoa
from math import pi, sin, cos

from fieldMath import degToRad

# Convience global variables
x, y = 0, 1
llc, sze = 0, 1 # Left Lower Corner, Size

#____________________________________________________________
class CGraphView (Cocoa.NSView):

    azmuthSlider = objc.IBOutlet()
    mapOffsetEWSlider = objc.IBOutlet()
    mapOffsetNSSlider = objc.IBOutlet()
    mapScaleSlider = objc.IBOutlet()
    mapVisibleSlider = objc.IBOutlet()
    azmuthDisplay = objc.IBOutlet()
    mapOffsetEWDisplay = objc.IBOutlet()
    mapOffsetNSDisplay = objc.IBOutlet()
    mapScaleDisplay = objc.IBOutlet()

    def initWithFrame_(self, frame):
        super(CGraphView, self).initWithFrame_(frame)
        self.setGridColor()
        self.setRmsColor()
        self.setGraphColor()
        self.graphMargin = 2
        self.mapImage = 0
        self.mapRect = 0
        self.mapVisible = 0.70
        self.mapScale = 3.0
        self.mapOffsetEW = 0.27
        self.mapOffsetNS = 0.40
        self.mapBaseRadius = 200

        self.lines = 2
        self.gain = 0.5
        return self

    def awakeFromNib(self):
        self.setCrossCursor()
        self.mapVisibleSlider.setFloatValue_(self.mapVisible)
        self.setAzmuth(125)
        self.setMapRect()

    def setCrossCursor(self):
        crosshairImage = Cocoa.NSImage.imageNamed_("CrossCursor")
        imageSize = crosshairImage.size()
        self.crossCursor = Cocoa.NSCursor.alloc().initWithImage_hotSpot_(crosshairImage, (8, 8))
        rect = self.bounds()
        self.trackingRect = self.addTrackingRect_owner_userData_assumeInside_(self.bounds(), self, 0, 0)

    def setGridColor(self, color=Cocoa.NSColor.greenColor()):
        self.gridColor = color

    def setRmsColor(self, color=Cocoa.NSColor.blueColor()):
        self.rmsColor = color

    def setGraphColor(self, color=Cocoa.NSColor.blackColor()):
        self.graphColor = color

    def setGain(self, gain, total):
        self.gain = gain
        self.totalField = total

    def setLines(self, lines):
        self.lines = lines

    def setMapImage(self, mapImage):
        self.mapImage = mapImage
        self.mapRect = ((0, 0), mapImage.size())

    def setPath(self, path, maxMag):
        self.path = path
        self.maxMag = maxMag
        self.setNeedsDisplay_(1)


    def drawRect_(self, rect):
        frame = self.frame()
        self.origin = frame[0]
        self.graphCenter = (frame[sze][x]/2, frame[sze][y]/2)
        self.graphRadius = (min(frame[sze][x], frame[sze][y]) / 2) - self.graphMargin

        Cocoa.NSColor.whiteColor().set()
        Cocoa.NSRectFill(self.bounds())

        self.drawMap()
        self.drawGrid()
        self.drawRMS()
        self.drawField()

    def drawMap(self):
        if self.mapImage == 0:
            return

        scale = self.mapScale * (self.graphRadius / self.mapBaseRadius) * self.gain / self.totalField
        xImageSize = scale * self.mapRect[sze][x]
        yImageSize = scale * self.mapRect[sze][y]
        xCenterMove = self.graphCenter[x] - self.graphRadius
        yCenterMove = self.graphCenter[y] - self.graphRadius

        xOffset = -((1 - self.mapOffsetEW) / 2) * xImageSize
        yOffset = -((1 + self.mapOffsetNS) / 2) * yImageSize
        xOffset += self.graphRadius + xCenterMove
        yOffset += self.graphRadius + yCenterMove

        drawInRect = ((xOffset, yOffset), (xImageSize, yImageSize))

        self.mapImage.drawInRect_fromRect_operation_fraction_(drawInRect, self.mapRect, Cocoa.NSCompositeSourceOver, self.mapVisible)

    def drawGrid(self):
        self.gridColor.set()
        self.drawCircle(1.0)
        self.drawAxisLines()

    def drawCircle(self, scale):
        center = self.graphCenter
        radius = self.graphRadius*scale
        x, y = 0, 1
        if radius >= 1:
            dotRect = ((center[x]-radius, center[y]-radius), (2*radius, 2*radius))
            path = Cocoa.NSBezierPath.bezierPathWithOvalInRect_(dotRect)
            path.stroke()

    def drawRMS(self):
        self.rmsColor.set()
        self.drawCircle(self.gain)

    def drawAxisLines(self):
        center = self.graphCenter
        radius = self.graphRadius
        x, y = 0, 1
        path = Cocoa.NSBezierPath.bezierPath()
        for i in range(1, self.lines+1):
            iR = pi / i
            cosR = cos(iR) * radius
            sinR = sin(iR) * radius

            path.moveToPoint_((center[x] - cosR, center[y] - sinR))
            path.lineToPoint_((center[x] + cosR, center[y] + sinR))
        path.closePath()
        path.stroke()

    def drawField(self):
        if self.maxMag:         # Don't want to divide by zero in the pathological case
            self.graphColor.set()
            path = self.path.copy()

            transform = Cocoa.NSAffineTransform.transform()
            transform.rotateByRadians_(-(pi / 2.0) - self.azmuth)
            path.transformUsingAffineTransform_(transform)

            transform = Cocoa.NSAffineTransform.transform()
            center = self.graphCenter
            transform.translateXBy_yBy_(center[0], center[1])
            transform.scaleBy_(self.graphRadius / self.maxMag)
            path.transformUsingAffineTransform_(transform)

            path.stroke()


#____________________________________________________________
# Handle GUI values
    @objc.IBAction
    def mapVisibleSlider_(self, sender):
        self.mapVisible = (sender.floatValue())
        self.setNeedsDisplay_(1)

    @objc.IBAction
    def azmuthDisplay_(self, sender):
        self.setAzmuth(sender.floatValue())

    @objc.IBAction
    def azmuthSlider_(self, sender):
        self.setAzmuth(sender.floatValue())

    def setAzmuth(self, value):
        self.azmuth = degToRad(value)
        self.azmuthSlider.setFloatValue_(value)
        self.azmuthDisplay.setFloatValue_(value)
        self.setNeedsDisplay_(1)

    @objc.IBAction
    def mapScaleDisplay_(self, sender):
        self.mapScale = sender.floatValue()
        self.setMapRect()

    @objc.IBAction
    def mapScaleSlider_(self, sender):
        self.mapScale = sender.floatValue()
        self.setMapRect()

    @objc.IBAction
    def mapOffsetNSDisplay_(self, sender):
        self.mapOffsetNS = sender.floatValue()
        self.setMapRect()

    @objc.IBAction
    def mapOffsetNSSlider_(self, sender):
        self.mapOffsetNS = sender.floatValue()
        self.setMapRect()

    @objc.IBAction
    def mapOffsetEWDisplay_(self, sender):
        self.mapOffsetEW = sender.floatValue()
        self.setMapRect()

    @objc.IBAction
    def mapOffsetEWSlider_(self, sender):
        self.mapOffsetEW = sender.floatValue()
        self.setMapRect()

    def mouseUp_(self, event):
        loc = event.locationInWindow()
        xLoc = loc[x] - self.origin[x]
        yLoc = loc[y] - self.origin[y]
        xDelta = self.graphCenter[x] - xLoc
        yDelta = self.graphCenter[y] - yLoc


        scale = 0.5 * self.mapScale * (self.gain / self.totalField) * (self.graphRadius / self.mapBaseRadius)
        xOffset = xDelta / (scale * self.mapRect[sze][x])
        yOffset = yDelta / (scale * self.mapRect[sze][y])

        self.mapOffsetEW += xOffset
        self.mapOffsetNS -= yOffset
        self.setMapRect()

    def mouseDown_(self, event):
        self.crossCursor.set()


    def setMapRect(self):
        self.mapScaleDisplay.setFloatValue_(self.mapScale)
        self.mapScaleSlider.setFloatValue_(self.mapScale)
        self.mapOffsetEWDisplay.setFloatValue_(self.mapOffsetEW)
        self.mapOffsetEWSlider.setFloatValue_(self.mapOffsetEW)
        self.mapOffsetNSDisplay.setFloatValue_(self.mapOffsetNS)
        self.mapOffsetNSSlider.setFloatValue_(self.mapOffsetNS)
        self.setNeedsDisplay_(1)

    def mouseEntered_(self, event):
        print('CGraphView: mouseEntered_')

    def mouseExited_(self, event):
        print('CGraphView: mouseExited_')

Main.py

from PyObjCTools import AppHelper
import CGraphController
import CGraphModel
import CGraphView

AppHelper.runEventLoop()

fieldMath.py

from math import pi, sin, cos, hypot, sqrt

# Math functions

def degToRad(deg):
    return (deg/180.0)*pi

def radToDeg(rad):
    return (rad/pi)*180.0

def polarToRect(polar):
    r = polar[0]
    theta = polar[1]
    return (r*cos(theta), r*sin(theta))

def bessel(z, t=0.00001):
    j = 1
    jn = 1
    zz4 = z*z/4
    for k in range(1, 100):
        jn *= -1 * zz4 / (k*k)
        j += jn

        if jn < 0:
            if jn > t:
                break
        else:
            if jn < t:
                break
    return j

setup.py

"""
Script for building the example.

Usage:
    python3 setup.py py2app
"""
from setuptools import setup

plist = dict(CFBundleName="FieldGraph")
setup(
    name="FieldGraph",
    app=["Main.py"],
    data_files=["English.lproj", "CrossCursor.tiff", "Map.png"],
    options=dict(py2app=dict(plist=plist)),
    setup_requires=[
        "py2app",
        "pyobjc-framework-Cocoa",
    ]
)

Resources