Ticket #3926: positioning-3926.patch

File positioning-3926.patch, 135.1 KB (added by lvh, 5 years ago)
  • new file w/twisted/positioning/__init__.py

    diff --git c/twisted/positioning/__init__.py w/twisted/positioning/__init__.py
    new file mode 100644
    index 0000000..3454a64
    - +  
     1# Copyright (c) 2009-2011 Twisted Matrix Laboratories.
     2# See LICENSE for details.
     3"""
     4The Twisted positioning framework.
     5
     6@since: 11.1
     7"""
  • new file w/twisted/positioning/base.py

    diff --git c/twisted/positioning/base.py w/twisted/positioning/base.py
    new file mode 100644
    index 0000000..1e707fb
    - +  
     1# -*- test-case-name: twisted.positioning.test.test_base,twisted.positioning.test.test_sentence -*-
     2# Copyright (c) 2009-2011 Twisted Matrix Laboratories.
     3# See LICENSE for details.
     4"""
     5Generic positioning base classes.
     6
     7@since: 11.1
     8"""
     9from zope.interface import implements
     10from twisted.python.util import FancyEqMixin
     11
     12from twisted.positioning import ipositioning
     13
     14MPS_PER_KNOT = 0.5144444444444444
     15MPS_PER_KPH = 0.27777777777777777
     16METERS_PER_FOOT = 0.3048
     17
     18LATITUDE, LONGITUDE, HEADING, VARIATION = range(4)
     19NORTH, EAST, SOUTH, WEST = range(4)
     20
     21
     22
     23class BasePositioningReceiver(object):
     24    """
     25    A base positioning receiver.
     26
     27    This class would be a good base class for building positioning
     28    receivers. It implements the interface (so you don't have to) with stub
     29    methods.
     30
     31    People who want to implement positioning receivers should subclass this
     32    class and override the specific callbacks they want to handle.
     33    """
     34    implements(ipositioning.IPositioningReceiver)
     35
     36    def timeReceived(self, time):
     37        """
     38        Implements L{IPositioningReceiver.timeReceived} stub.
     39        """
     40
     41
     42    def headingReceived(self, heading):
     43        """
     44        Implements L{IPositioningReceiver.headingReceived} stub.
     45        """
     46
     47
     48    def speedReceived(self, speed):
     49        """
     50        Implements L{IPositioningReceiver.speedReceived} stub.
     51        """
     52
     53
     54    def climbReceived(self, climb):
     55        """
     56        Implements L{IPositioningReceiver.climbReceived} stub.
     57        """
     58
     59
     60    def positionReceived(self, latitude, longitude):
     61        """
     62        Implements L{IPositioningReceiver.positionReceived} stub.
     63        """
     64
     65
     66    def positionErrorReceived(self, positionError):
     67        """
     68        Implements L{IPositioningReceiver.positioningErrorReceived} stub.
     69        """
     70
     71
     72    def altitudeReceived(self, altitude):
     73        """
     74        Implements L{IPositioningReceiver.altitudeReceived} stub.
     75        """
     76
     77
     78    def beaconInformationReceived(self, beaconInformation):
     79        """
     80        Implements L{IPositioningReceiver.beaconInformationReceived} stub.
     81        """
     82
     83
     84
     85class InvalidSentence(Exception):
     86    """
     87    An exception raised when a sentence is invalid.
     88    """
     89
     90
     91
     92class InvalidChecksum(Exception):
     93    """
     94    An exception raised when the checksum of a sentence is invalid.
     95    """
     96
     97
     98class BaseSentence(object):
     99    """
     100    A base sentence class for a particular protocol.
     101
     102    Using this base class, specific sentence classes can almost automatically
     103    be created for a particular protocol (except for the documentation of
     104    course) if that protocol implements the L{IPositioningSentenceProducer}
     105    interface. To do this, fill the ALLOWED_ATTRIBUTES class attribute using
     106    the C{getSentenceAttributes} class method of the producer::
     107
     108        class FooSentence(BaseSentence):
     109            \"\"\"
     110            A sentence for integalactic transmodulator sentences.
     111
     112            @ivar transmogrificationConstant: The value used in the
     113                transmogrifier while producing this sentence, corrected for
     114                gravitational fields.
     115            @type transmogrificationConstant: C{Tummy}
     116            \"\"\"
     117            ALLOWED_ATTRIBUTES = FooProtocol.getSentenceAttributes()
     118
     119    @ivar presentAttribues: An iterable containing the names of the
     120        attributes that are present in this sentence.
     121    @type presentAttributes: iterable of C{str}
     122
     123    @cvar ALLOWED_ATTRIBUTES: A set of attributes that are allowed in this
     124        sentence.
     125    @type ALLOWED_ATTRIBUTES: C{set} of C{str}
     126    """
     127    ALLOWED_ATTRIBUTES = set()
     128
     129
     130    def __init__(self, sentenceData):
     131        """
     132        Initializes a sentence with parsed sentence data.
     133
     134        @param sentenceData: The parsed sentence data.
     135        @type sentenceData: C{dict} (C{str} -> C{str} or C{NoneType})
     136        """
     137        self._sentenceData = sentenceData
     138
     139
     140    presentAttributes = property(lambda self: iter(self._sentenceData))
     141
     142
     143    def __getattr__(self, name):
     144        """
     145        Gets an attribute of this sentence.
     146        """
     147        if name in self.ALLOWED_ATTRIBUTES:
     148            return self._sentenceData.get(name, None)
     149        else:
     150            className = self.__class__.__name__
     151            msg = "%s sentences have no %s attributes" % (className, name)
     152            raise AttributeError(msg)
     153
     154
     155    def __repr__(self):
     156        """
     157        Returns a textual representation of this sentence.
     158
     159        @return: A textual representation of this sentence.
     160        @rtype: C{str}
     161        """
     162        items = self._sentenceData.items()
     163        data = ["%s: %s" % (k, v) for k, v in sorted(items) if k != "type"]
     164        dataRepr = ", ".join(data)
     165
     166        typeRepr = self._sentenceData.get("type") or "unknown type"
     167        className = self.__class__.__name__
     168
     169        return "<%s (%s) {%s}>" % (className, typeRepr, dataRepr)
     170
     171
     172
     173class PositioningSentenceProducerMixin(object):
     174    """
     175    A mixin for certain protocols that produce positioning sentences.
     176
     177    This mixin helps protocols that have C{SENTENCE_CONTENTS} class variables
     178    (such as the C{NMEAProtocol} and the C{ClassicGPSDProtocol}) implement the
     179    L{IPositioningSentenceProducingProtocol} interface.
     180    """
     181    #@classmethod
     182    def getSentenceAttributes(cls):
     183        """
     184        Returns a set of all attributes that might be found in the sentences
     185        produced by this protocol.
     186
     187        This is basically a set of all the attributes of all the sentences that
     188        this protocol can produce.
     189
     190        @return: The set of all possible sentence attribute names.
     191        @rtype: C{set} of C{str}
     192        """
     193        attributes = set(["type"])
     194        for attributeList in cls.SENTENCE_CONTENTS.values():
     195            for attribute in attributeList:
     196                if attribute is None:
     197                    continue
     198                attributes.add(attribute)
     199
     200        return attributes
     201
     202
     203    getSentenceAttributes = classmethod(getSentenceAttributes)
     204
     205
     206   
     207class Angle(object, FancyEqMixin):
     208    """
     209    An object representing an angle.
     210
     211    @ivar inDecimalDegrees: The value of this angle, expressed in decimal
     212        degrees. C{None} if unknown. This attribute is read-only.
     213    @type inDecimalDegrees: C{float} (or C{NoneType})
     214    @ivar inDegreesMinutesSeconds: The value of this angle, expressed in
     215        degrees, minutes and seconds. C{None} if unknown. This attribute is
     216        read-only.
     217    @type inDegreesMinutesSeconds: 3-C{tuple} of C{int} (or C{NoneType})
     218
     219    @cvar RANGE_EXPRESSIONS: A collections of expressions for the allowable
     220        range for the angular value of a particular coordinate value.
     221    @type RANGE_EXPRESSIONS: A mapping of coordinate types (C{LATITUDE},
     222        C{LONGITUDE}, C{HEADING}, C{VARIATION}) to 1-argument callables.
     223    """
     224    RANGE_EXPRESSIONS = {
     225        LATITUDE: lambda latitude: -90.0 < latitude < 90.0,
     226        LONGITUDE: lambda longitude: -180.0 < longitude < 180.0,
     227        HEADING: lambda heading:  0 <= heading < 360,
     228        VARIATION: lambda variation: -180 < variation <= 180,
     229    }
     230
     231
     232    ANGLE_TYPE_NAMES  = {
     233        LATITUDE: "latitude",
     234        LONGITUDE: "longitude",
     235        VARIATION: "variation",
     236        HEADING: "heading",
     237    }
     238
     239
     240    compareAttributes = 'angleType', 'inDecimalDegrees'
     241
     242
     243    def __init__(self, angle=None, angleType=None):
     244        """
     245        Initializes an angle.
     246
     247        @param angle: The value of the angle in decimal degrees. (C{None} if
     248            unknown).
     249        @type angle: C{float} or C{NoneType}
     250        @param angleType: A symbolic constant describing the angle type. Should
     251            be one of LATITUDE, LONGITUDE, HEADING, VARIATION. C{None} if
     252            unknown.
     253
     254        @raises ValueError: If the angle type is not the default argument, but it
     255            is an unknown type (it's not present in C{Angle.RANGE_EXPRESSIONS}),
     256            or it is a known type but the supplied value was out of the allowable
     257            range for said type.
     258        """
     259        if angle is not None and angleType is not None:
     260            if angleType not in self.RANGE_EXPRESSIONS:
     261                raise ValueError("Unknown angle type")
     262            elif not self.RANGE_EXPRESSIONS[angleType](angle):
     263                raise ValueError("Angle %s not in allowed range for type %s"
     264                                 % (angle, self.ANGLE_TYPE_NAMES[angleType]))
     265
     266        self.angleType = angleType
     267        self._angle = angle
     268
     269
     270    inDecimalDegrees = property(lambda self: self._angle)
     271
     272
     273    def _getDMS(self):
     274        """
     275        Gets the value of this angle as a degrees, minutes, seconds tuple.
     276
     277        @return: This angle expressed in degrees, minutes, seconds. C{None} if
     278            the angle is unknown.
     279        @rtype: 3-C{tuple} of C{int} (or C{NoneType})
     280        """
     281        if self._angle is None:
     282            return None
     283
     284        degrees = abs(int(self._angle))
     285        fractionalDegrees = abs(self._angle - int(self._angle))
     286        decimalMinutes = 60 * fractionalDegrees
     287
     288        minutes = int(decimalMinutes)
     289        fractionalMinutes = decimalMinutes - int(decimalMinutes)
     290        decimalSeconds = 60 * fractionalMinutes
     291
     292        return degrees, minutes, int(decimalSeconds)
     293
     294
     295    inDegreesMinutesSeconds = property(_getDMS)
     296
     297
     298    def setSign(self, sign):
     299        """
     300        Sets the sign of this angle.
     301
     302        @param sign: The new sign. C{1} for positive and C{-1} for negative
     303            signs, respectively.
     304        @type sign: C{int}
     305
     306        @raise ValueError: If the C{sign} parameter is not C{-1} or C{1}.
     307        """
     308        if sign not in (-1, 1):
     309            raise ValueError("bad sign (got %s, expected -1 or 1)" % sign)
     310
     311        self._angle = sign * abs(self._angle)
     312
     313
     314    def __float__(self):
     315        """
     316        Returns this angle as a float.
     317
     318        @return: The float value of this angle, expressed in degrees.
     319        @rtype: C{float}
     320        """
     321        return self._angle
     322
     323
     324    def __repr__(self):
     325        """
     326        Returns a string representation of this angle.
     327
     328        @return: The string representation.
     329        @rtype: C{str}
     330        """
     331        return "<%s (%s)>" % (self._angleTypeNameRepr, self._angleValueRepr)
     332
     333
     334    def _getAngleValueRepr(self):
     335        """
     336        Returns a string representation of the angular value of this angle.
     337
     338        This is a helper function for the actual C{__repr__}.
     339
     340        @return: The string representation.
     341        @rtype: C{str}
     342        """
     343        if self.inDecimalDegrees is not None:
     344            return "%s degrees" % round(self.inDecimalDegrees, 2)
     345        else:
     346            return "unknown value"
     347
     348
     349    _angleValueRepr = property(_getAngleValueRepr)
     350
     351
     352    def _getAngleTypeNameRepr(self):
     353        """
     354        Returns a string representation of the type of this angle.
     355
     356        This is a helper function for the actual C{__repr__}.
     357
     358        @return: The string representation.
     359        @rtype: C{str}
     360        """
     361        angleTypeName = self.ANGLE_TYPE_NAMES.get(
     362            self.angleType, "angle of unknown type").capitalize()
     363        return angleTypeName
     364
     365
     366    _angleTypeNameRepr = property(_getAngleTypeNameRepr)
     367
     368
     369
     370class Heading(Angle):
     371    """
     372    The heading of a mobile object.
     373
     374    @ivar variation: The (optional) variation.
     375        The sign of the variation is positive for variations towards the east
     376        (clockwise from north), and negative for variations towards the west
     377        (counterclockwise from north).
     378        If the variation is unknown or not applicable, this is C{None}.
     379    @type variation: C{Angle} or C{NoneType}.
     380    @ivar correctedHeading: The heading, corrected for variation. If the
     381        variation is unknown (C{None}), is None. This attribute is read-only (its
     382        value is determined by the angle and variation attributes). The value is
     383        coerced to being between 0 (inclusive) and 360 (exclusive).
     384    """
     385    def __init__(self, angle=None, variation=None):
     386        """
     387        Initializes a angle with an optional variation.
     388        """
     389        Angle.__init__(self, angle, HEADING)
     390        self.variation = variation
     391
     392
     393    #@classmethod
     394    def fromFloats(cls, angleValue=None, variationValue=None):
     395        """
     396        Constructs a Heading from the float values of the angle and variation.
     397
     398        @param angleValue: The angle value of this heading.
     399        @type angleValue: C{float}
     400        @param variationValue: The value of the variation of this heading.
     401        @type variationValue: C{float}
     402        """
     403        variation = Angle(variationValue, VARIATION)
     404        return cls(angleValue, variation)
     405
     406
     407    fromFloats = classmethod(fromFloats)
     408
     409
     410    def _getCorrectedHeading(self):
     411        """
     412        Corrects the heading by the given variation. This is sometimes known as
     413        the true heading.
     414
     415        @return: The heading, corrected by the variation. If the variation or
     416            the angle are unknown, returns C{None}.
     417        @rtype: C{float} or C{NoneType}
     418        """
     419        if self._angle is None or self.variation is None:
     420            return None
     421
     422        angle = (self.inDecimalDegrees - self.variation.inDecimalDegrees) % 360
     423        return Angle(angle, HEADING)
     424
     425
     426    correctedHeading = property(_getCorrectedHeading)
     427
     428
     429    def setSign(self, sign):
     430        """
     431        Sets the sign of the variation of this heading.
     432
     433        @param sign: The new sign. C{1} for positive and C{-1} for negative
     434            signs, respectively.
     435        @type sign: C{int}
     436
     437        @raise ValueErorr: If the C{sign} parameter is not C{-1} or C{1}.
     438        """
     439        if self.variation.inDecimalDegrees is None:
     440            raise ValueError("can't set the sign of an unknown variation")
     441
     442        self.variation.setSign(sign)
     443
     444
     445    compareAttributes = list(Angle.compareAttributes) + ["variation"]
     446
     447
     448    def __repr__(self):
     449        """
     450        Returns a string representation of this angle.
     451
     452        @return: The string representation.
     453        @rtype: C{str}
     454        """
     455        if self.variation is None:
     456            variationRepr = "unknown variation"
     457        else:
     458            variationRepr = repr(self.variation)
     459
     460        return "<%s (%s, %s)>" % (
     461            self._angleTypeNameRepr, self._angleValueRepr, variationRepr)
     462
     463
     464
     465class Coordinate(Angle, FancyEqMixin):
     466    """
     467    A coordinate.
     468
     469    @ivar angle: The value of the coordinate in decimal degrees, with the usual
     470        rules for sign (northern and eastern hemispheres are positive, southern
     471        and western hemispheres are negative).
     472    @type angle: C{float}
     473    """
     474    def __init__(self, angle, coordinateType=None):
     475        """
     476        Initializes a coordinate.
     477
     478        @param angle: The angle of this coordinate in decimal degrees. The
     479            hemisphere is determined by the sign (north and east are positive).
     480            If this coordinate describes a latitude, this value must be within
     481            -90.0 and +90.0 (exclusive). If this value describes a longitude,
     482            this value must be within -180.0 and +180.0 (exclusive).
     483        @type angle: C{float}
     484        @param coordinateType: One of L{LATITUDE}, L{LONGITUDE}. Used to return
     485            hemisphere names.
     486        """
     487        Angle.__init__(self, angle, coordinateType)
     488
     489
     490    HEMISPHERES_BY_TYPE_AND_SIGN = {
     491        LATITUDE: [
     492            NORTH, # positive
     493            SOUTH, # negative
     494        ],
     495
     496        LONGITUDE: [
     497            EAST, # positve
     498            WEST, # negative
     499        ]
     500    }
     501
     502
     503    def _getHemisphere(self):
     504        """
     505        Gets the hemisphere of this coordinate.
     506
     507        @return: A symbolic constant representing a hemisphere (C{NORTH},
     508            C{EAST}, C{SOUTH} or C{WEST}).
     509        """
     510        try:
     511            sign = int(self.inDecimalDegrees < 0)
     512            return self.HEMISPHERES_BY_TYPE_AND_SIGN[self.angleType][sign]
     513        except KeyError:
     514            raise ValueError("unknown coordinate type (cant find hemisphere)")
     515
     516
     517    hemisphere = property(fget=_getHemisphere)
     518
     519
     520
     521class Altitude(object, FancyEqMixin):
     522    """
     523    An altitude.
     524
     525    @ivar inMeters: The altitude represented by this object, in meters. This
     526        attribute is read-only.
     527    @type inMeters: C{float}
     528
     529    @ivar inFeet: As above, but expressed in feet.
     530    @type inFeet: C{float}
     531    """
     532    compareAttributes = 'inMeters',
     533
     534    def __init__(self, altitude):
     535        """
     536        Initializes an altitude.
     537
     538        @param altitude: The altitude in meters.
     539        @type altitude: C{float}
     540        """
     541        self._altitude = altitude
     542
     543
     544    def _getAltitudeInFeet(self):
     545        """
     546        Gets the altitude this object represents, in feet.
     547
     548        @return: The altitude, expressed in feet.
     549        @rtype: C{float}
     550        """
     551        return self._altitude / METERS_PER_FOOT
     552
     553
     554    inFeet = property(_getAltitudeInFeet)
     555
     556
     557    def _getAltitudeInMeters(self):
     558        """
     559        Returns the altitude this object represents, in meters.
     560
     561        @return: The altitude, expressed in feet.
     562        @rtype: C{float}
     563        """
     564        return self._altitude
     565
     566
     567    inMeters = property(_getAltitudeInMeters)
     568
     569
     570    def __float__(self):
     571        """
     572        Returns the altitude represented by this object expressed in meters.
     573
     574        @return: The altitude represented by this object, expressed in meters.
     575        @rtype: C{float}
     576        """
     577        return self._altitude
     578
     579
     580    def __repr__(self):
     581        """
     582        Returns a string representation of this altitude.
     583
     584        @return: The string representation.
     585        @rtype: C{str}
     586        """
     587        return "<Altitude (%s m)>" % (self._altitude,)
     588
     589
     590
     591class _BaseSpeed(object, FancyEqMixin):
     592    """
     593    An object representing the abstract concept of the speed (rate of
     594    movement) of a mobile object.
     595
     596    This primarily has behavior for converting between units and comparison.
     597
     598    @ivar inMetersPerSecond: The speed that this object represents, expressed
     599        in meters per second. This attribute is immutable.
     600    @type inMetersPerSecond: C{float}
     601
     602    @ivar inKnots: Same as above, but expressed in knots.
     603    @type inKnots: C{float}
     604    """
     605    compareAttributes = 'inMetersPerSecond',
     606
     607    def __init__(self, speed):
     608        """
     609        Initializes a speed.
     610
     611        @param speed: The speed that this object represents, expressed in
     612            meters per second.
     613        @type speed: C{float}
     614
     615        @raises ValueError: Raised if value was invalid for this particular
     616            kind of speed. Only happens in subclasses.
     617        """
     618        self._speed = speed
     619
     620
     621    def _getSpeedInKnots(self):
     622        """
     623        Returns the speed represented by this object, expressed in knots.
     624
     625        @return: The speed this object represents, in knots.
     626        @rtype: C{float}
     627        """
     628        return self._speed / MPS_PER_KNOT
     629
     630
     631    inKnots = property(_getSpeedInKnots)
     632
     633
     634    inMetersPerSecond = property(lambda self: self._speed)
     635
     636
     637    def __float__(self):
     638        """
     639        Returns the speed represented by this object expressed in meters per
     640        second.
     641
     642        @return: The speed represented by this object, expressed in meters per
     643            second.
     644        @rtype: C{float}
     645        """
     646        return self._speed
     647
     648
     649    def __repr__(self):
     650        """
     651        Returns a string representation of this speed object.
     652
     653        @return: The string representation.
     654        @rtype: C{str}
     655        """
     656        speedValue = round(self.inMetersPerSecond, 2)
     657        return "<%s (%s m/s)>" % (self.__class__.__name__, speedValue)
     658
     659
     660
     661class Speed(_BaseSpeed):
     662    """
     663    The speed (rate of movement) of a mobile object.
     664    """
     665    def __init__(self, speed):
     666        """
     667        Initializes a L{Speed} object.
     668
     669        @param speed: The speed that this object represents, expressed in
     670            meters per second.
     671        @type speed: C{float}
     672
     673        @raises ValueError: Raised if C{speed} is negative.
     674        """
     675        if speed < 0:
     676            raise ValueError("negative speed: %r" % (speed,))
     677
     678        _BaseSpeed.__init__(self, speed)
     679
     680
     681
     682class Climb(_BaseSpeed):
     683    """
     684    The climb ("vertical speed") of an object.
     685    """
     686    def __init__(self, climb):
     687        """
     688        Initializes a L{Clib} object.
     689
     690        @param climb: The climb that this object represents, expressed in
     691            meters per second.
     692        @type climb: C{float}
     693
     694        @raises ValueError: Raised if the provided climb was less than zero.
     695        """
     696        _BaseSpeed.__init__(self, climb)
     697
     698
     699
     700class PositionError(object, FancyEqMixin):
     701    """
     702    Position error information.
     703
     704    @ivar pdop: The position dilution of precision. C{None} if unknown.
     705    @type pdop: C{float} or C{NoneType}
     706    @ivar hdop: The horizontal dilution of precision. C{None} if unknown.
     707    @type hdop: C{float} or C{NoneType}
     708    @ivar vdop: The vertical dilution of precision. C{None} if unknown.
     709    @type vdop: C{float} or C{NoneType}
     710    """
     711    compareAttributes = 'pdop', 'hdop', 'vdop'
     712
     713    def __init__(self, pdop=None, hdop=None, vdop=None, testInvariant=False):
     714        """
     715        Initializes a positioning error object.
     716
     717        @param pdop: The position dilution of precision. C{None} if unknown.
     718        @type pdop: C{float} or C{NoneType}
     719        @param hdop: The horizontal dilution of precision. C{None} if unknown.
     720        @type hdop: C{float} or C{NoneType}
     721        @param vdop: The vertical dilution of precision. C{None} if unknown.
     722        @type vdop: C{float} or C{NoneType}
     723        @param testInvariant: Flag to test if the DOP invariant is valid or
     724            not. If C{True}, the invariant (PDOP = (HDOP**2 + VDOP**2)*.5) is
     725            checked at every mutation. By default, this is false, because the
     726            vast majority of DOP-providing devices ignore this invariant.
     727        @type testInvariant: c{bool}
     728        """
     729        self._pdop = pdop
     730        self._hdop = hdop
     731        self._vdop = vdop
     732
     733        self._testInvariant = testInvariant
     734        self._testDilutionOfPositionInvariant()
     735
     736
     737    ALLOWABLE_TRESHOLD = 0.01
     738
     739
     740    def _testDilutionOfPositionInvariant(self):
     741        """
     742        Tests if this positioning error object satisfies the dilution of
     743        position invariant (PDOP = (HDOP**2 + VDOP**2)*.5), unless the
     744        C{self._testInvariant} instance variable is C{False}.
     745
     746        @return: C{None} if the invariant was not satisifed or not tested.
     747        @raises ValueError: Raised if the invariant was tested but not
     748            satisfied.
     749        """
     750        if not self._testInvariant:
     751            return
     752
     753        for x in (self.pdop, self.hdop, self.vdop):
     754            if x is None:
     755                return
     756
     757        delta = abs(self.pdop - (self.hdop**2 + self.vdop**2)**.5)
     758        if delta > self.ALLOWABLE_TRESHOLD:
     759            raise ValueError("invalid combination of dilutions of precision: "
     760                             "position: %s, horizontal: %s, vertical: %s"
     761                             % (self.pdop, self.hdop, self.vdop))
     762
     763
     764    DOP_EXPRESSIONS = {
     765        'pdop': [
     766            lambda self: float(self._pdop),
     767            lambda self: (self._hdop**2 + self._vdop**2)**.5,
     768        ],
     769
     770        'hdop': [
     771            lambda self: float(self._hdop),
     772            lambda self: (self._pdop**2 - self._vdop**2)**.5,
     773        ],
     774
     775        'vdop': [
     776            lambda self: float(self._vdop),
     777            lambda self: (self._pdop**2 - self._hdop**2)**.5,
     778        ],
     779    }
     780
     781
     782    def _getDOP(self, dopType):
     783        """
     784        Gets a particular dilution of position value.
     785
     786        @return: The DOP if it is known, C{None} otherwise.
     787        @rtype: C{float} or C{NoneType}
     788        """
     789        for dopExpression in self.DOP_EXPRESSIONS[dopType]:
     790            try:
     791                return dopExpression(self)
     792            except TypeError:
     793                continue
     794
     795
     796    def _setDOP(self, dopType, value):
     797        """
     798        Sets a particular dilution of position value.
     799
     800        @param dopType: The type of dilution of position to set. One of
     801            ('pdop', 'hdop', 'vdop').
     802        @type dopType: C{str}
     803
     804        @param value: The value to set the dilution of position type to.
     805        @type value: C{float}
     806
     807        If this position error tests dilution of precision invariants,
     808        it will be checked. If the invariant is not satisfied, the
     809        assignment will be undone and C{ValueError} is raised.
     810        """
     811        attributeName = "_" + dopType
     812
     813        oldValue = getattr(self, attributeName)
     814        setattr(self, attributeName, float(value))
     815
     816        try:
     817            self._testDilutionOfPositionInvariant()
     818        except ValueError:
     819            setattr(self, attributeName, oldValue)
     820            raise
     821
     822
     823    pdop = property(fget=lambda self: self._getDOP('pdop'),
     824                    fset=lambda self, value: self._setDOP('pdop', value))
     825
     826
     827    hdop = property(fget=lambda self: self._getDOP('hdop'),
     828                    fset=lambda self, value: self._setDOP('hdop', value))
     829
     830
     831    vdop = property(fget=lambda self: self._getDOP('vdop'),
     832                    fset=lambda self, value: self._setDOP('vdop', value))
     833
     834
     835    _REPR_TEMPLATE = "<PositionError (pdop: %s, hdop: %s, vdop: %s)>"
     836
     837
     838    def __repr__(self):
     839        """
     840        Returns a string representation of positioning information object.
     841
     842        @return: The string representation.
     843        @rtype: C{str}
     844        """
     845        return self._REPR_TEMPLATE % (self.pdop, self.hdop, self.vdop)
     846
     847
     848
     849class BeaconInformation(object):
     850    """
     851    Information about positioning beacons (a generalized term for the reference
     852    objects that help you determine your position, such as satellites or cell
     853    towers).
     854
     855    @ivar beacons: A set of visible beacons. Note that visible beacons are not
     856        necessarily used in acquiring a postioning fix.
     857    @type beacons: C{set} of L{IPositioningBeacon}
     858
     859    @ivar usedBeacons: An iterable of the beacons that were used in obtaining a
     860        positioning fix. This only contains beacons that are actually used, not
     861        beacons of which it is  unknown if they are used or not. This attribute
     862        is immutable.
     863    @type usedBeacons: iterable of L{IPositioningBeacon}
     864
     865    @ivar seen: The amount of beacons that can be seen. This attribute is
     866        immutable.
     867    @type seen: C{int}
     868    @ivar used: The amount of beacons that were used in obtaining the
     869        positioning fix. This attribute is immutable.
     870    @type used: C{int}
     871    """
     872    def __init__(self, beacons=None):
     873        """
     874        Initializes a beacon information object.
     875
     876        @param beacons: A collection of beacons that will be present in this
     877            beacon information object.
     878        @type beacons: iterable of L{IPositioningBeacon} or C{Nonetype}
     879        """
     880        self.beacons = set(beacons or [])
     881
     882
     883    def _getUsedBeacons(self):
     884        """
     885        Returns a generator of used beacons.
     886
     887        @return: A generator containing all of the used positioning beacons. This
     888            only contains beacons that are actually used, not beacons of which it
     889            is  unknown if they are used or not.
     890        @rtype: iterable of L{PositioningBeacon}
     891        """
     892        for beacon in self.beacons:
     893            if beacon.isUsed:
     894                yield beacon
     895
     896
     897    usedBeacons = property(fget=_getUsedBeacons)
     898
     899
     900    def _getNumberOfBeaconsSeen(self):
     901        """
     902        Returns the number of beacons that can be seen.
     903
     904        @return: The number of beacons that can be seen.
     905        @rtype: C{int}
     906        """
     907        return len(self.beacons)
     908
     909
     910    seen = property(_getNumberOfBeaconsSeen)
     911
     912
     913    def _getNumberOfBeaconsUsed(self):
     914        """
     915        Returns the number of beacons that can be seen.
     916
     917        @return: The number of beacons that can be seen, or C{None} if the number
     918            is unknown. This happens as soon as one of the beacons has an unknown
     919            (C{None}) C{isUsed} attribute.
     920        @rtype: C{int} or C{NoneType}
     921        """
     922        numberOfUsedBeacons = 0
     923        for beacon in self.beacons:
     924            if beacon.isUsed is None:
     925                return None
     926            elif beacon.isUsed:
     927                numberOfUsedBeacons += 1
     928        return numberOfUsedBeacons
     929
     930
     931    used = property(_getNumberOfBeaconsUsed)
     932
     933
     934    def __iter__(self):
     935        """
     936        Yields the beacons in this beacon information object.
     937
     938        @return: A generator producing the beacons in this beacon information
     939            object.
     940        @rtype: iterable of L{PositioningBeacon}
     941        """
     942        for beacon in self.beacons:
     943            yield beacon
     944
     945
     946    def __repr__(self):
     947        """
     948        Returns a string representation of this beacon information object.
     949
     950        The beacons are sorted by their identifier.
     951
     952        @return: The string representation.
     953        @rtype: C{str}
     954        """
     955        beaconReprs = ", ".join([repr(beacon) for beacon in
     956            sorted(self.beacons, key=lambda x: x.identifier)])
     957
     958        if self.used is not None:
     959            used = str(self.used)
     960        else:
     961            used = "?"
     962
     963        return "<BeaconInformation (seen: %s, used: %s, beacons: {%s})>" % (
     964            self.seen, used, beaconReprs)
     965
     966
     967
     968class PositioningBeacon(object):
     969    """
     970    A positioning beacon.
     971
     972    @ivar identifier: The unqiue identifier for this satellite. This is usually
     973        an integer. For GPS, this is also known as the PRN.
     974    @type identifier: Pretty much anything that can be used as a unique
     975        identifier. Depends on the implementation.
     976    @ivar isUsed: C{True} if the satellite is currently being used to obtain a
     977        fix, C{False} if it is not currently being used, C{None} if unknown.
     978    @type isUsed: C{bool} or C{NoneType}
     979    """
     980    def __init__(self, identifier, isUsed=None):
     981        """
     982        Initializes a positioning beacon.
     983
     984        @param identifier: The identifier for this beacon.
     985        @type identifier: Can be pretty much anything (see ivar documentation).
     986        @param isUsed: Determines if this beacon is used in obtaining a
     987            positioning fix (see the ivar documentation).
     988        @type isUsed: C{bool} or C{NoneType}
     989        """
     990        self.identifier = identifier
     991        self.isUsed = isUsed
     992
     993
     994    def __hash__(self):
     995        """
     996        Returns the hash of the identifier for this beacon.
     997
     998        @return: The hash of the identifier. (C{hash(self.identifier)})
     999        @rtype: C{int}
     1000        """
     1001        return hash(self.identifier)
     1002
     1003
     1004    def _usedRepr(self):
     1005        """
     1006        Returns a single character representation of the status of this
     1007        satellite in terms of being used for attaining a positioning fix.
     1008
     1009        @return: One of ("Y", "N", "?") depending on the status of the
     1010            satellite.
     1011        @rtype: C{str}
     1012        """
     1013        return {True: "Y", False: "N", None: "?"}[self.isUsed]
     1014
     1015
     1016    def __repr__(self):
     1017        """
     1018        Returns a string representation of this beacon.
     1019
     1020        @return: The string representation.
     1021        @rtype: C{str}
     1022        """
     1023        return "<Beacon (identifier: %s, used: %s)>" \
     1024            % (self.identifier, self._usedRepr())
     1025
     1026
     1027
     1028class Satellite(PositioningBeacon):
     1029    """
     1030    A satellite.
     1031
     1032    @ivar azimuth: The azimuth of the satellite. This is the heading (positive
     1033        angle relative to true north) where the satellite appears to be to the
     1034        device.
     1035    @ivar elevation: The (positive) angle above the horizon where this
     1036        satellite appears to be to the device.
     1037    @ivar signalToNoiseRatio: The signal to noise ratio of the signal coming
     1038        from this satellite.
     1039    """
     1040    def __init__(self,
     1041                 identifier,
     1042                 azimuth=None,
     1043                 elevation=None,
     1044                 signalToNoiseRatio=None,
     1045                 isUsed=None):
     1046        """
     1047        Initializes a satellite object.
     1048
     1049        @param identifier: The PRN (unique identifier) of this satellite.
     1050        @type identifier: C{int}
     1051        @param azimuth: The azimuth of the satellite (see instance variable
     1052            documentation).
     1053        @type azimuth: C{float}
     1054        @param elevation: The elevation of the satellite (see instance variable
     1055            documentation).
     1056        @type elevation: C{float}
     1057        @param signalToNoiseRatio: The signal to noise ratio of the connection
     1058            to this satellite (see instance variable documentation).
     1059        @type signalToNoiseRatio: C{float}
     1060
     1061        """
     1062        super(Satellite, self).__init__(int(identifier), isUsed)
     1063
     1064        self.azimuth = azimuth
     1065        self.elevation = elevation
     1066        self.signalToNoiseRatio = signalToNoiseRatio
     1067
     1068
     1069    def __repr__(self):
     1070        """
     1071        Returns a string representation of this Satellite.
     1072
     1073        @return: The string representation.
     1074        @rtype: C{str}
     1075        """
     1076        azimuth, elevation, snr = [{None: "?"}.get(x, x)
     1077            for x in self.azimuth, self.elevation, self.signalToNoiseRatio]
     1078
     1079        properties = "azimuth: %s, elevation: %s, snr: %s" % (
     1080            azimuth, elevation, snr)
     1081
     1082        return "<Satellite (%s), %s, used: %s>" % (
     1083            self.identifier, properties, self._usedRepr())
  • new file w/twisted/positioning/ipositioning.py

    diff --git c/twisted/positioning/ipositioning.py w/twisted/positioning/ipositioning.py
    new file mode 100644
    index 0000000..403d738
    - +  
     1# Copyright (c) 2009-2011 Twisted Matrix Laboratories.
     2# See LICENSE for details.
     3"""
     4Positioning interfaces.
     5
     6@since: 11.1
     7"""
     8from zope.interface import Interface
     9
     10
     11class IPositioningReceiver(Interface):
     12    """
     13    An interface for positioning providers.
     14    """
     15    def positionReceived(latitude, longitude):
     16        """
     17        Method called when a position is received.
     18
     19        @param latitude: The latitude of the received position.
     20        @type latitude: L{twisted.positioning.base.Coordinate}
     21        @param longitude: The longitude of the received position.
     22        @type longitude: L{twisted.positioning.base.Coordinate}
     23        """
     24
     25
     26    def positionErrorReceived(positionError):
     27        """
     28        Method called when position error is received.
     29
     30        @param positioningError: The position error.
     31        @type positioningError: L{twisted.positioning.base.PositionError}
     32        """
     33
     34    def timeReceived(time):
     35        """
     36        Method called when time and date information arrives.
     37
     38        @param time: The date and time (expressed in UTC unless otherwise
     39            specified).
     40        @type time: L{datetime.datetime}
     41        """
     42
     43
     44    def headingReceived(heading):
     45        """
     46        Method called when a true heading is received.
     47
     48        @param heading: The heading.
     49        @type heading: L{twisted.positioning.base.Heading}
     50        """
     51
     52
     53    def altitudeReceived(altitude):
     54        """
     55        Method called when an altitude is received.
     56
     57        @param altitude: The altitude.
     58        @type altitude: L{twisted.positioning.base.Altitude}
     59        """
     60
     61
     62    def speedReceived(speed):
     63        """
     64        Method called when the speed is received.
     65
     66        @param speed: The speed of a mobile object.
     67        @type speed: L{twisted.positioning.base.Speed}
     68        """
     69
     70
     71    def climbReceived(climb):
     72        """
     73        Method called when the climb is received.
     74
     75        @param climb: The climb of the mobile object.
     76        @type climb: L{twisted.positioning.base.Climb}
     77        """
     78
     79    def beaconInformationReceived(beaconInformation):
     80        """
     81        Method called when positioning beacon information is received.
     82
     83        @param beaconInformation: The beacon information.
     84        @type beaconInformation: L{twisted.positioning.base.BeaconInformation}
     85        """
     86
     87
     88
     89class INMEAReceiver(Interface):
     90    """
     91    An object that can receive NMEA data.
     92    """
     93    def sentenceReceived(sentence):
     94        """
     95        Method called when a sentence is received.
     96
     97        @param sentence: The received NMEA sentence.
     98        @type L{twisted.positioning.nmea.NMEASentence}
     99        """
     100
     101
     102
     103class IPositioningSentenceProducer(Interface):
     104    """
     105    A protocol that produces positioning sentences.
     106
     107    Implementing this protocol allows sentence classes to be automagically
     108    generated for a particular protocol.
     109    """
     110    def getSentenceAttributes(self):
     111        """
     112        Returns a set of attributes that might be present in a sentence produced
     113        by this sentence producer.
     114
     115        @return: A set of attributes that might be present in a given sentence.
     116        @rtype: C{set} of C{str}
     117        """
  • new file w/twisted/positioning/nmea.py

    diff --git c/twisted/positioning/nmea.py w/twisted/positioning/nmea.py
    new file mode 100644
    index 0000000..c25aefe
    - +  
     1# -*- test-case-name: twisted.positioning.test.test_nmea -*-
     2# Copyright (c) 2009-2011 Twisted Matrix Laboratories.
     3# See LICENSE for details.
     4"""
     5Classes for working with NMEA (and vaguely NMEA-like) sentence producing
     6devices.
     7
     8@since: 11.1
     9"""
     10
     11import itertools
     12import operator
     13import datetime
     14from zope.interface import implements, classProvides
     15
     16from twisted.protocols.basic import LineReceiver
     17from twisted.positioning import base, ipositioning
     18from twisted.positioning.base import LATITUDE, LONGITUDE, VARIATION
     19
     20# GPGGA fix quality:
     21(GGA_INVALID_FIX, GGA_GPS_FIX, GGA_DGPS_FIX, GGA_PPS_FIX, GGA_RTK_FIX,
     22 GGA_FLOAT_RTK_FIX, GGA_DEAD_RECKONING, GGA_MANUAL_FIX, GGA_SIMULATED_FIX
     23 ) = [str(x) for x in range(9)]
     24
     25# GPGLL/GPRMC fix quality:
     26DATA_ACTIVE, DATA_VOID = "A", "V"
     27
     28# Selection modes (used in a variety of sentences):
     29MODE_AUTO, MODE_MANUAL = 'A', 'M'
     30
     31# GPGSA fix types:
     32GSA_NO_FIX, GSA_2D_FIX, GSA_3D_FIX = '1', '2', '3'
     33
     34NMEA_NORTH, NMEA_EAST, NMEA_SOUTH, NMEA_WEST = "N", "E", "S", "W"
     35
     36
     37def split(sentence):
     38    """
     39    Returns the split version of an NMEA sentence, minus header
     40    and checksum.
     41
     42    @param sentence: The NMEA sentence to split.
     43    @type sentence: C{str}
     44
     45    >>> split("$GPGGA,spam,eggs*00")
     46    ['GPGGA', 'spam', 'eggs']
     47    """
     48    if sentence[-3] == "*": # sentence with checksum
     49        return sentence[1:-3].split(',')
     50    elif sentence[-1] == "*": # sentence without checksum
     51        return sentence[1:-1].split(',')
     52    else:
     53        raise base.InvalidSentence("malformed sentence %s" % sentence)
     54
     55
     56def validateChecksum(sentence):
     57    """
     58    Validates the checksum of an NMEA sentence.
     59
     60    @param sentence: The NMEA sentence to check the checksum of.
     61    @type sentence: C{str}
     62
     63    @raise ValueError: If the sentence has an invalid checksum.
     64
     65    Simply returns on sentences that either don't have a checksum,
     66    or have a valid checksum.
     67    """
     68    if sentence[-3] == '*': # sentence has a checksum
     69        reference, source = int(sentence[-2:], 16), sentence[1:-3]
     70        computed = reduce(operator.xor, (ord(x) for x in source))
     71        if computed != reference:
     72            raise base.InvalidChecksum("%02x != %02x" % (computed, reference))
     73
     74
     75
     76class NMEAProtocol(LineReceiver, base.PositioningSentenceProducerMixin):
     77    """
     78    A protocol that parses and verifies the checksum of an NMEA sentence (in
     79    string form, not L{NMEASentence}), and delegates to a receiver.
     80
     81    It receives lines and verifies these lines are NMEA sentences. If
     82    they are, verifies their checksum and unpacks them into their
     83    components. It then wraps them in L{NMEASentence} objects and
     84    calls the appropriate receiver method with them.
     85    """
     86    classProvides(ipositioning.IPositioningSentenceProducer)
     87    METHOD_PREFIX = "nmea_"
     88
     89    def __init__(self, receiver):
     90        """
     91        Initializes an NMEAProtocol.
     92
     93        @param receiver: A receiver for NMEAProtocol sentence objects.
     94        @type receiver: L{INMEAReceiver}
     95        """
     96        self.receiver = receiver
     97
     98
     99    def lineReceived(self, rawSentence):
     100        """
     101        Parses the data from the sentence and validates the checksum.
     102
     103        @param rawSentence: The MMEA positioning sentence.
     104        @type rawSentence: C{str}
     105        """
     106        sentence = rawSentence.strip()
     107
     108        validateChecksum(sentence)
     109        splitSentence = split(sentence)
     110
     111        sentenceType, contents = splitSentence[0], splitSentence[1:]
     112
     113        try:
     114            keys = self.SENTENCE_CONTENTS[sentenceType]
     115        except KeyError:
     116            raise ValueError("unknown sentence type %s" % sentenceType)
     117
     118        sentenceData = {"type": sentenceType}
     119        for key, value in itertools.izip(keys, contents):
     120            if key is not None and value != "":
     121                sentenceData[key] = value
     122
     123        sentence = NMEASentence(sentenceData)
     124
     125        try:
     126            callback = getattr(self, self.METHOD_PREFIX + sentenceType)
     127            callback(sentence)
     128        except AttributeError:
     129            pass # No sentence-specific callback on the protocol
     130
     131        if self.receiver is not None:
     132            self.receiver.sentenceReceived(sentence)
     133
     134
     135    SENTENCE_CONTENTS = {
     136        'GPGGA': [
     137            'timestamp',
     138
     139            'latitudeFloat',
     140            'latitudeHemisphere',
     141            'longitudeFloat',
     142            'longitudeHemisphere',
     143
     144            'fixQuality',
     145            'numberOfSatellitesSeen',
     146            'horizontalDilutionOfPrecision',
     147
     148            'altitude',
     149            'altitudeUnits',
     150            'heightOfGeoidAboveWGS84',
     151            'heightOfGeoidAboveWGS84Units',
     152
     153            # The next parts are DGPS information.
     154        ],
     155
     156        'GPRMC': [
     157            'timestamp',
     158
     159            'dataMode',
     160
     161            'latitudeFloat',
     162            'latitudeHemisphere',
     163            'longitudeFloat',
     164            'longitudeHemisphere',
     165
     166            'speedInKnots',
     167
     168            'trueHeading',
     169
     170            'datestamp',
     171
     172            'magneticVariation',
     173            'magneticVariationDirection',
     174        ],
     175
     176        'GPGSV': [
     177            'numberOfGSVSentences',
     178            'GSVSentenceIndex',
     179
     180            'numberOfSatellitesSeen',
     181
     182            'satellitePRN_0',
     183            'elevation_0',
     184            'azimuth_0',
     185            'signalToNoiseRatio_0',
     186
     187            'satellitePRN_1',
     188            'elevation_1',
     189            'azimuth_1',
     190            'signalToNoiseRatio_1',
     191
     192            'satellitePRN_2',
     193            'elevation_2',
     194            'azimuth_2',
     195            'signalToNoiseRatio_2',
     196
     197            'satellitePRN_3',
     198            'elevation_3',
     199            'azimuth_3',
     200            'signalToNoiseRatio_3',
     201        ],
     202
     203        'GPGLL': [
     204            'latitudeFloat',
     205            'latitudeHemisphere',
     206            'longitudeFloat',
     207            'longitudeHemisphere',
     208            'timestamp',
     209            'dataMode',
     210        ],
     211
     212        'GPHDT': [
     213            'trueHeading',
     214        ],
     215
     216        'GPTRF': [
     217            'datestamp',
     218            'timestamp',
     219
     220            'latitudeFloat',
     221            'latitudeHemisphere',
     222            'longitudeFloat',
     223            'longitudeHemisphere',
     224
     225            'elevation',
     226            'numberOfIterations', # unused
     227            'numberOfDopplerIntervals', # unused
     228            'updateDistanceInNauticalMiles', # unused
     229            'satellitePRN',
     230        ],
     231
     232        'GPGSA': [
     233            'dataMode',
     234            'fixType',
     235
     236            'usedSatellitePRN_0',
     237            'usedSatellitePRN_1',
     238            'usedSatellitePRN_2',
     239            'usedSatellitePRN_3',
     240            'usedSatellitePRN_4',
     241            'usedSatellitePRN_5',
     242            'usedSatellitePRN_6',
     243            'usedSatellitePRN_7',
     244            'usedSatellitePRN_8',
     245            'usedSatellitePRN_9',
     246            'usedSatellitePRN_10',
     247            'usedSatellitePRN_11',
     248
     249            'positionDilutionOfPrecision',
     250            'horizontalDilutionOfPrecision',
     251            'verticalDilutionOfPrecision',
     252        ]
     253    }
     254
     255
     256class NMEASentence(base.BaseSentence):
     257    """
     258    An object representing an NMEA sentence.
     259
     260    The attributes of this objects are raw NMEA protocol data, which
     261    are all ASCII bytestrings.
     262
     263    This object contains all the raw NMEA protocol data in a single
     264    sentence.  Not all of these necessarily have to be present in the
     265    sentence. Missing attributes are None when accessed.
     266
     267    Sentence-specific junk:
     268
     269    @ivar type: The sentence type ("GPGGA", "GPGSV"...).
     270    @ivar numberOfGSVSentences: The total number of GSV sentences in a
     271        sequence.
     272    @ivar GSVSentenceIndex: The index of this GSV sentence in the GSV
     273        sequence.
     274
     275    Time-related attributes:
     276
     277    @ivar timestamp: A timestamp. ("123456" -> 12:34:56Z)
     278    @ivar datestamp: A datestamp. ("230394" -> 23 Mar 1994)
     279
     280    Location-related attributes:
     281
     282    @ivar latitudeFloat: Latitude value. (for example: "1234.567" ->
     283        12 degrees, 34.567 minutes).
     284    @ivar latitudeHemisphere: Latitudinal hemisphere ("N" or "S").
     285    @ivar longitudeFloat: Longitude value. See C{latitudeFloat} for an
     286        example.
     287    @ivar longitudeHemisphere: Longitudinal hemisphere ("E" or "W").
     288    @ivar altitude: The altitude above mean sea level.
     289    @ivar altitudeUnits: Units in which altitude is expressed. (Always
     290        "M" for meters.)
     291    @ivar heightOfGeoidAboveWGS84: The local height of the geoid above
     292        the WGS84 ellipsoid model.
     293    @ivar heightOfGeoidAboveWGS84Units: The units in which the height
     294        above the geoid is expressed. (Always "M" for meters.)
     295
     296    Attributes related to direction and movement:
     297
     298    @ivar trueHeading: The true heading.
     299    @ivar magneticVariation: The magnetic variation.
     300    @ivar magneticVariationDirection: The direction of the magnetic
     301        variation. One of C{"E"} or C{"W"}.
     302    @ivar speedInKnots: The ground speed, expressed in knots.
     303
     304    Attributes related to fix and data quality:
     305
     306    @ivar fixQuality: The quality of the fix. This is a single digit
     307        from C{"0"} to C{"8"}. The important ones are C{"0"} (invalid
     308        fix), C{"1"} (GPS fix) and C{"2"} (DGPS fix).
     309    @ivar dataMode: Signals if the data is usable or not. One of
     310        L{DATA_ACTIVE} or L{DATA_VOID}.
     311    @ivar numberOfSatellitesSeen: The number of satellites seen by the
     312        receiver.
     313    @ivar numberOfSatellitesUsed: The number of satellites used in
     314        computing the fix.
     315
     316    Attributes related to precision:
     317
     318    @ivar horizontalDilutionOfPrecision: The dilution of the precision of the
     319        position on a plane tangential to the geoid. (HDOP)
     320    @ivar verticalDilutionOfPrecision: As C{horizontalDilutionOfPrecision},
     321        but for a position on a plane perpendicular to the geoid. (VDOP)
     322    @ivar positionDilutionOfPrecision: Euclidian norm of HDOP and VDOP.
     323
     324    Attributes related to satellite-specific data:
     325
     326    @ivar C{satellitePRN}: The unique identifcation number of a particular
     327        satelite. Optionally suffixed with C{_N} if multiple satellites are
     328        referenced in a sentence, where C{N in range(4)}.
     329    @ivar C{elevation}: The elevation of a satellite in decimal degrees.
     330        Optionally suffixed with C{_N}, as with C{satellitePRN}.
     331    @ivar C{azimuth}: The azimuth of a satellite in decimal degrees.
     332        Optionally suffixed with C{_N}, as with C{satellitePRN}.
     333    @ivar C{signalToNoiseRatio}: The SNR of a satellite signal, in decibels.
     334        Optionally suffixed with C{_N}, as with C{satellitePRN}.
     335    @ivar C{usedSatellitePRN_N}: Where C{int(N) in range(12)}. The PRN
     336        of a satelite used in computing the fix.
     337
     338    """
     339    ALLOWED_ATTRIBUTES = NMEAProtocol.getSentenceAttributes()
     340   
     341    def _isFirstGSVSentence(self):
     342        """
     343        Tests if this current GSV sentence is the first one in a sequence.
     344        """
     345        return self.GSVSentenceIndex == "1"
     346
     347
     348    def _isLastGSVSentence(self):
     349        """
     350        Tests if this current GSV sentence is the final one in a sequence.
     351        """
     352        return self.GSVSentenceIndex == self.numberOfGSVSentences
     353
     354
     355
     356class NMEAAdapter(object):
     357    """
     358    An adapter from NMEAProtocol receivers to positioning receivers.
     359
     360    @cvar DATESTAMP_HANDLING: Determines the way incomplete (two-digit) NMEA
     361        datestamps are handled.. One of L{INTELLIGENT_DATESTAMPS} (default,
     362        assumes dates are twenty-first century if the two-digit date is below
     363        the L{INTELLIGENT_DATE_THRESHOLD}, twentieth century otherwise),
     364        L{DATESTAMPS_FROM_20XX} (assumes all dates are twenty-first century),
     365        L{DATESTAMPS_FROM_19XX} (assumes all dates are twentieth century).
     366        All of these are class attributes of this class.
     367
     368    @cvar INTELLIGENT_DATE_THRESHOLD: The threshold that determines which
     369        century we guess a year is in. If the year value in a sentence is above
     370        this value, assumes the 20th century (19xx), otherwise assumes the
     371        twenty-first century (20xx).
     372    @type INTELLIGENT_DATE_THRESHOLD: L{int}
     373    """
     374    implements(ipositioning.INMEAReceiver)
     375
     376
     377    def __init__(self, receiver):
     378        """
     379        Initializes a new NMEA adapter.
     380
     381        @param receiver: The receiver for positioning sentences.
     382        @type receiver: L{twisted.positioning.IPositioningReceiver}
     383        """
     384        self._state = {}
     385        self._sentenceData = {}
     386        self._receiver = receiver
     387
     388
     389    def _fixTimestamp(self):
     390        """
     391        Turns the NMEAProtocol timestamp notation into a datetime.time object.
     392        The time in this object is expressed as Zulu time.
     393        """
     394        timestamp = self.currentSentence.timestamp.split('.')[0]
     395        timeObject = datetime.datetime.strptime(timestamp, '%H%M%S').time()
     396        self._sentenceData['_time'] = timeObject
     397
     398
     399    INTELLIGENT_DATESTAMPS = 0
     400    DATESTAMPS_FROM_20XX = 1
     401    DATESTAMPS_FROM_19XX = 2
     402
     403    DATESTAMP_HANDLING = INTELLIGENT_DATESTAMPS
     404    INTELLIGENT_DATE_THRESHOLD = 80
     405
     406
     407    def _fixDatestamp(self):
     408        """
     409        Turns an NMEA datestamp format into a C{datetime.date} object.
     410        """
     411        datestamp = self.currentSentence.datestamp
     412
     413        day, month, year = [int(ordinalString) for ordinalString in
     414                            (datestamp[0:2], datestamp[2:4], datestamp[4:6])]
     415
     416        if self.DATESTAMP_HANDLING == self.INTELLIGENT_DATESTAMPS:
     417            if year > self.INTELLIGENT_DATE_THRESHOLD:
     418                year = int('19%02d' % year)
     419            else:
     420                year = int('20%02d' % year)
     421
     422        elif self.DATESTAMP_HANDLING == self.DATESTAMPS_FROM_20XX:
     423            year = int('20%02d' % year)
     424
     425        elif self.DATESTAMP_HANDLING == self.DATESTAMPS_FROM_19XX:
     426            year = int('19%02d' % year)
     427
     428        else:
     429            raise ValueError("unknown datestamp handling method (%s)"
     430                             % (self.DATESTAMP_HANDLING,))
     431
     432        self._sentenceData['_date'] = datetime.date(year, month, day)
     433
     434
     435    def _fixCoordinateFloat(self, coordinateType):
     436        """
     437        Turns the NMEAProtocol coordinate format into Python float.
     438
     439        @param coordinateType: The coordinate type. Should be L{base.LATITUDE}
     440            or L{base.LONGITUDE}.
     441        """
     442        coordinateName = base.Coordinate.ANGLE_TYPE_NAMES[coordinateType]
     443        key = coordinateName + 'Float'
     444        nmeaCoordinate = getattr(self.currentSentence, key)
     445
     446        left, right = nmeaCoordinate.split('.')
     447
     448        degrees, minutes = int(left[:-2]), float("%s.%s" % (left[-2:], right))
     449        angle = degrees + minutes/60
     450        coordinate = base.Coordinate(angle, coordinateType)
     451        self._sentenceData[coordinateName] = coordinate
     452
     453
     454    def _fixHemisphereSign(self, coordinateType, sentenceDataKey=None):
     455        """
     456        Fixes the sign for a hemisphere.
     457
     458        This method must be called after the magnitude for the thing it
     459        determines the sign of has been set. This is done by the following
     460        functions:
     461
     462            - C{self.FIXERS['magneticVariation']}
     463            - C{self.FIXERS['latitudeFloat']}
     464            - C{self.FIXERS['longitudeFloat']}
     465
     466        @param coordinateType: Coordinate type. One of L{base.LATITUDE},
     467            L{base.LONGITUDE} or L{base.VARIATION}.
     468        """
     469        sentenceDataKey = sentenceDataKey or coordinateType
     470        sign = self._getHemisphereSign(coordinateType)
     471        self._sentenceData[sentenceDataKey].setSign(sign)
     472
     473
     474    COORDINATE_SIGNS = {
     475        NMEA_NORTH: 1,
     476        NMEA_EAST: 1,
     477        NMEA_SOUTH: -1,
     478        NMEA_WEST: -1
     479    }
     480
     481
     482    def _getHemisphereSign(self, coordinateType):
     483        """
     484        Returns the hemisphere sign for a given coordinate type.
     485
     486        @param coordinateType: Coordinate type. One of L{base.LATITUDE},
     487            L{base.LONGITUDE} or L{base.VARIATION}.
     488        """
     489        if coordinateType in (LATITUDE, LONGITUDE):
     490            hemisphereKey = (base.Coordinate.ANGLE_TYPE_NAMES[coordinateType]
     491                             + 'Hemisphere')
     492        elif coordinateType == VARIATION:
     493            hemisphereKey = 'magneticVariationDirection'
     494        else:
     495            raise ValueError("unknown coordinate type %s" % (coordinateType,))
     496
     497        hemisphere = getattr(self.currentSentence, hemisphereKey)
     498
     499        try:
     500           return self.COORDINATE_SIGNS[hemisphere.upper()]
     501        except KeyError:
     502            raise ValueError("bad hemisphere/direction: %s" % hemisphere)
     503
     504
     505    def _convert(self, sourceKey, converter=float, destinationKey=None):
     506        """
     507        A simple conversion fix.
     508
     509        @param sourceKey: The attribute name of the value to fix.
     510        @type sourceKey: C{str} (Python identifier)
     511
     512        @param converter: The function that converts the value.
     513        @type converter: unary callable
     514
     515        @param destinationKey: The target attribute key. If unset or
     516            C{None}, same as C{sourceKey}.
     517        @type destinationKey: C{str} (Python identifier)
     518        """
     519        currentValue = getattr(self.currentSentence, sourceKey)
     520
     521        if destinationKey is None:
     522            destinationKey = sourceKey
     523
     524        self._sentenceData[destinationKey] = converter(currentValue)
     525
     526
     527
     528    STATEFUL_UPDATE = {
     529        # sentenceKey: (stateKey, factory, attributeName, converter),
     530        'trueHeading':
     531            ('heading', base.Heading, '_angle', float),
     532        'magneticVariation':
     533            ('heading', base.Heading, 'variation',
     534             lambda angle: base.Angle(float(angle), VARIATION)),
     535
     536        'horizontalDilutionOfPrecision':
     537            ('positionError', base.PositionError, 'hdop', float),
     538        'verticalDilutionOfPrecision':
     539            ('positionError', base.PositionError, 'vdop', float),
     540        'positionDilutionOfPrecision':
     541            ('positionError', base.PositionError, 'pdop', float),
     542
     543    }
     544
     545
     546    def _statefulUpdate(self, sentenceKey):
     547        """
     548        Does a stateful update of a particular positioning attribute.
     549
     550        @param sentenceKey: The name of the key in the sentence attributes,
     551            C{NMEAAdapter.STATEFUL_UPDATE} dictionary and the adapter state.
     552        @type sentenceKey: C{str}
     553        """
     554        state, factory, attr, converter = self.STATEFUL_UPDATE[sentenceKey]
     555
     556        if state not in self._sentenceData:
     557            self._sentenceData[state] = self._state.get(state, factory())
     558
     559        newValue = converter(getattr(self.currentSentence, sentenceKey))
     560        setattr(self._sentenceData[state], attr, newValue)
     561
     562
     563    ACCEPTABLE_UNITS = frozenset(['M'])
     564    UNIT_CONVERTERS = {
     565        'N': lambda inKnots: base.Speed(float(inKnots) * base.MPS_PER_KNOT),
     566        'K': lambda inKPH: base.Speed(float(inKPH) * base.MPS_PER_KPH),
     567    }
     568
     569
     570    def _fixUnits(self, unitKey=None, valueKey=None, sourceKey=None, unit=None):
     571        """
     572        Fixes the units of a certain value.
     573
     574        @param unit: The unit that is being converted I{from}. If unspecified
     575            or None, asks the current sentence for the C{unitKey}. If that also
     576            fails, raises C{AttributeError}.
     577        @type unit: C{str}
     578        @param unitKey: The name of the key/attribute under which the unit can
     579            be found in the current sentence. If the C{unit} parameter is set,
     580            this parameter is not used.
     581        @type unitKey: C{str}
     582        @param sourceKey: The name of the key/attribute that contains the
     583            current value to be converted (expressed in units as defined
     584            according to the the C{unit} parameter). If unset, will use the
     585            same key as the value key.
     586        @type sourceKey: C{str}
     587        @param valueKey: The key name in which the data will be stored in the
     588            C{_sentenceData} instance attribute. If unset, attempts to strip
     589            "Units" from the C{unitKey} parameter.
     590        @type valueKey: C{str}
     591
     592        None of the keys are allowed to be the empty string.
     593        """
     594        unit = unit or getattr(self.currentSentence, unitKey)
     595        valueKey = valueKey or unitKey.strip('Units')
     596        sourceKey = sourceKey or valueKey
     597
     598        if unit not in self.ACCEPTABLE_UNITS:
     599            converter = self.UNIT_CONVERTERS[unit]
     600            currentValue = getattr(self.currentSentence, sourceKey)
     601            self._sentenceData[valueKey] = converter(currentValue)
     602
     603
     604    GSV_KEYS = "satellitePRN", "azimuth", "elevation", "signalToNoiseRatio"
     605
     606
     607    def _fixGSV(self):
     608        """
     609        Parses partial visible satellite information from a GSV sentence.
     610        """
     611        # To anyone who knows NMEA, this method's name should raise a chuckle's
     612        # worth of schadenfreude. 'Fix' GSV? Hah! Ludicrous.
     613        self._sentenceData['_partialBeaconInformation'] = base.BeaconInformation()
     614
     615        for index in range(4):
     616            keys = ["%s_%i" % (key, index) for key in self.GSV_KEYS]
     617            values = [getattr(self.currentSentence, k) for k in keys]
     618            prn, azimuth, elevation, snr = values
     619
     620            if prn is None or snr is None:
     621                # The peephole optimizer optimizes the jump away, meaning that
     622                # coverage.py isn't covered. It is. Replace it with break and
     623                # watch the test case fail.
     624                # ML thread about this issue: http://goo.gl/1KNUi
     625                # Related CPython bug: http://bugs.python.org/issue2506
     626                continue # pragma: no cover
     627
     628            satellite = base.Satellite(prn, azimuth, elevation, snr)
     629            bi = self._sentenceData['_partialBeaconInformation']
     630            bi.beacons.add(satellite)
     631
     632
     633    def _fixGSA(self):
     634        """
     635        Extracts the information regarding which satellites were used in
     636        obtaining the GPS fix from a GSA sentence.
     637
     638        @precondition: A GSA sentence was fired.
     639        @postcondition: The current sentence data (C{self._sentenceData} will
     640            contain a set of the currently used PRNs (under the key
     641            C{_usedPRNs}.
     642        """
     643        self._sentenceData['_usedPRNs'] = set()
     644        for key in ("usedSatellitePRN_%d" % x for x in range(12)):
     645            prn = getattr(self.currentSentence, key, None)
     646            if prn is not None:
     647                self._sentenceData['_usedPRNs'].add(int(prn))
     648
     649
     650    SPECIFIC_SENTENCE_FIXES = {
     651        'GPGSV': _fixGSV,
     652        'GPGSA': _fixGSA,
     653    }
     654
     655
     656    def _sentenceSpecificFix(self):
     657        """
     658        Executes a fix for a specific type of sentence.
     659        """
     660        fixer = self.SPECIFIC_SENTENCE_FIXES.get(self.currentSentence.type)
     661        if fixer is not None:
     662            fixer(self)
     663
     664
     665    FIXERS = {
     666        'type':
     667            lambda self: self._sentenceSpecificFix(),
     668
     669        'timestamp':
     670            lambda self: self._fixTimestamp(),
     671        'datestamp':
     672            lambda self: self._fixDatestamp(),
     673
     674        'latitudeFloat':
     675            lambda self: self._fixCoordinateFloat(LATITUDE),
     676        'latitudeHemisphere':
     677            lambda self: self._fixHemisphereSign(LATITUDE, 'latitude'),
     678        'longitudeFloat':
     679            lambda self: self._fixCoordinateFloat(LONGITUDE),
     680        'longitudeHemisphere':
     681            lambda self: self._fixHemisphereSign(LONGITUDE, 'longitude'),
     682
     683        'altitude':
     684            lambda self: self._convert('altitude',
     685                converter=lambda strRepr: base.Altitude(float(strRepr))),
     686        'altitudeUnits':
     687            lambda self: self._fixUnits(unitKey='altitudeUnits'),
     688
     689        'heightOfGeoidAboveWGS84':
     690            lambda self: self._convert('heightOfGeoidAboveWGS84',
     691                converter=lambda strRepr: base.Altitude(float(strRepr))),
     692        'heightOfGeoidAboveWGS84Units':
     693            lambda self: self._fixUnits(
     694                unitKey='heightOfGeoidAboveWGS84Units'),
     695
     696        'trueHeading':
     697            lambda self: self._statefulUpdate('trueHeading'),
     698        'magneticVariation':
     699            lambda self: self._statefulUpdate('magneticVariation'),
     700
     701        'magneticVariationDirection':
     702            lambda self: self._fixHemisphereSign(VARIATION,
     703                                                 'heading'),
     704
     705        'speedInKnots':
     706            lambda self: self._fixUnits(valueKey='speed',
     707                                        sourceKey='speedInKnots',
     708                                        unit='N'),
     709
     710        'positionDilutionOfPrecision':
     711            lambda self: self._statefulUpdate('positionDilutionOfPrecision'),
     712        'horizontalDilutionOfPrecision':
     713            lambda self: self._statefulUpdate('horizontalDilutionOfPrecision'),
     714        'verticalDilutionOfPrecision':
     715            lambda self: self._statefulUpdate('verticalDilutionOfPrecision'),
     716    }
     717
     718
     719    def clear(self):
     720        """
     721        Resets this adapter.
     722
     723        This will empty the adapter state and the current sentence data.
     724        """
     725        self._state = {}
     726        self._sentenceData = {}
     727
     728
     729    def sentenceReceived(self, sentence):
     730        """
     731        Called when a sentence is received.
     732
     733        Will clean the received NMEAProtocol sentence up, and then update the
     734        adapter's state, followed by firing the callbacks.
     735
     736        If the received sentence was invalid, the state will be cleared.
     737
     738        @param sentence: The sentence that is received.
     739        @type sentence: L{NMEASentence}
     740        """
     741        self.currentSentence = sentence
     742
     743        try:
     744            self._validateCurrentSentence()
     745            self._cleanCurrentSentence()
     746        except base.InvalidSentence:
     747            self.clear()
     748
     749        self._updateSentence()
     750        self._fireSentenceCallbacks()
     751
     752
     753    def _validateCurrentSentence(self):
     754        """
     755        Tests if a sentence contains a valid fix.
     756        """
     757        if (self.currentSentence.fixQuality == GGA_INVALID_FIX
     758            or self.currentSentence.dataMode == DATA_VOID
     759            or self.currentSentence.fixType == GSA_NO_FIX):
     760            raise base.InvalidSentence("bad sentence")
     761
     762
     763    def _cleanCurrentSentence(self):
     764        """
     765        Cleans the current sentence.
     766        """
     767        for key in sorted(self.currentSentence.presentAttributes):
     768            fixer = self.FIXERS.get(key, None)
     769
     770            if fixer is not None:
     771                fixer(self)
     772
     773
     774    def _updateSentence(self):
     775        """
     776        Updates the current state with the new information from the sentence.
     777        """
     778        self._updateBeaconInformation()
     779        self._combineDateAndTime()
     780        self._state.update(self._sentenceData)
     781
     782
     783    def _updateBeaconInformation(self):
     784        """
     785        Updates existing beacon information state with new data.
     786        """
     787        new = self._sentenceData.get('_partialBeaconInformation')
     788        if new is None:
     789            return
     790
     791        usedPRNs = (self._state.get('_usedPRNs')
     792                    or self._sentenceData.get('_usedPRNs'))
     793        if usedPRNs is not None:
     794            for beacon in new.beacons:
     795                beacon.isUsed = (beacon.identifier in usedPRNs)
     796
     797        old = self._state.get('_partialBeaconInformation')
     798        if old is not None:
     799            new.beacons.update(old.beacons)
     800
     801        if self.currentSentence._isLastGSVSentence():
     802            if not self.currentSentence._isFirstGSVSentence():
     803                # not a 1-sentence sequence, get rid of partial information
     804                del self._state['_partialBeaconInformation']
     805            bi = self._sentenceData.pop('_partialBeaconInformation')
     806            self._sentenceData['beaconInformation'] = bi
     807
     808
     809    def _combineDateAndTime(self):
     810        """
     811        Combines a C{datetime.date} object and a C{datetime.time} object,
     812        collected from one or more NMEA sentences, into a single
     813        C{datetime.datetime} object suitable for sending to the
     814        L{IPositioningReceiver}.
     815        """
     816        if not ('_date' in self._sentenceData or '_time' in self._sentenceData):
     817            return
     818
     819        date, time = [self._sentenceData.get(key) or self._state.get(key)
     820                      for key in ('_date', '_time')]
     821
     822        if date is None or time is None:
     823            return
     824
     825        dt = datetime.datetime.combine(date, time)
     826        self._sentenceData['time'] = dt
     827
     828
     829    def _fireSentenceCallbacks(self):
     830        """
     831        Fires sentence callbacks for the current sentence.
     832
     833        A callback will only fire if all of the keys it requires are present in
     834        the current state and at least one such field was altered in the
     835        current sentence.
     836
     837        The callbacks will only be fired with data from L{self._state}.
     838        """
     839        for callbackName, requiredFields in self.REQUIRED_CALLBACK_FIELDS.items():
     840            callback = getattr(self._receiver, callbackName)
     841
     842            kwargs = {}
     843            atLeastOnePresentInSentence = False
     844
     845            try:
     846                for field in requiredFields:
     847                    if field in self._sentenceData:
     848                        atLeastOnePresentInSentence = True
     849                    kwargs[field] = self._state[field]
     850            except KeyError:
     851                continue
     852
     853            if atLeastOnePresentInSentence:
     854                callback(**kwargs)
     855
     856
     857
     858NMEAAdapter.REQUIRED_CALLBACK_FIELDS = dict(
     859    (name, method.positional) for name, method
     860    in ipositioning.IPositioningReceiver.namesAndDescriptions())
  • new file w/twisted/positioning/test/__init__.py

    diff --git c/twisted/positioning/test/__init__.py w/twisted/positioning/test/__init__.py
    new file mode 100644
    index 0000000..fcd5611
    - +  
     1# Copyright (c) 2009-2011 Twisted Matrix Laboratories.
     2# See LICENSE for details.
     3"""
     4Tests for the Twisted positioning framework.
     5"""
  • new file w/twisted/positioning/test/test_base.py

    diff --git c/twisted/positioning/test/test_base.py w/twisted/positioning/test/test_base.py
    new file mode 100644
    index 0000000..95fdba6
    - +  
     1# Copyright (c) 2009-2011 Twisted Matrix Laboratories.
     2# See LICENSE for details.
     3"""
     4Test cases for positioning primitives.
     5"""
     6from twisted.trial.unittest import TestCase
     7from twisted.positioning import base
     8from twisted.positioning.base import LATITUDE, LONGITUDE
     9from twisted.positioning.base import NORTH, EAST, SOUTH, WEST
     10
     11
     12class AngleTests(TestCase):
     13    """
     14    Tests for the L{twisted.positioning.base.Angle} class.
     15    """
     16    def test_empty(self):
     17        """
     18        Tests the repr of an empty angle.
     19        """
     20        a = base.Angle()
     21        self.assertEquals("<Angle of unknown type (unknown value)>", repr(a))
     22
     23
     24    def test_variation(self):
     25        """
     26        Tests the repr of an empty variation.
     27        """
     28        a = base.Angle(angleType=base.VARIATION)
     29        self.assertEquals("<Variation (unknown value)>", repr(a))
     30
     31
     32    def test_unknownType(self):
     33        """
     34        Tests the repr of an unknown angle of a 1 decimal degree value.
     35        """
     36        a = base.Angle(1.0)
     37        self.assertEquals("<Angle of unknown type (1.0 degrees)>", repr(a))
     38
     39
     40
     41class HeadingTests(TestCase):
     42    """
     43    Tests for the L{twisted.positioning.base.Heading} class.
     44    """
     45    def test_simple(self):
     46        """
     47        Tests some of the basic features of a very simple heading.
     48        """
     49        h = base.Heading(1.)
     50        self.assertEquals(h.inDecimalDegrees, 1.)
     51        self.assertEquals(h.variation, None)
     52        self.assertEquals(h.correctedHeading, None)
     53        self.assertEquals(float(h), 1.)
     54
     55
     56    def test_headingWithoutVariationRepr(self):
     57        """
     58        Tests the repr of a heading without a variation.
     59        """
     60        h = base.Heading(1.)
     61        self.assertEquals(repr(h), "<Heading (1.0 degrees, unknown variation)>")
     62
     63
     64    def test_headingWithVariationRepr(self):
     65        """
     66        Tests the repr of a heading with a variation.
     67        """
     68        angle, variation = 1.0, -10.0
     69        h = base.Heading.fromFloats(angle, variationValue=variation)
     70
     71        variationRepr = '<Variation (%s degrees)>' % (variation,)
     72        expectedRepr = '<Heading (%s degrees, %s)>' % (angle, variationRepr)
     73        self.assertEquals(repr(h), expectedRepr)
     74
     75
     76    def test_equality(self):
     77        """
     78        Tests if equal headings compare equal.
     79        """
     80        self.assertEquals(base.Heading(1.), base.Heading(1.))
     81
     82
     83    def test_inequality(self):
     84        """
     85        Tests if unequal headings compare unequal.
     86        """
     87        self.assertNotEquals(base.Heading(1.), base.Heading(2.))
     88
     89
     90    def test_edgeCases(self):
     91        """
     92        Tests that the two edge cases of a heading value of zero and a heading
     93        value of zero with a variation of C{180.0} don't fail.
     94        """
     95        base.Heading(0)
     96        base.Heading(0, 180)
     97
     98
     99    def _badValueTest(self, **kw):
     100        """
     101        Helper function for verifying that bad values raise C{ValueError}.
     102
     103        Passes C{**kw} to L{base.Heading.fromFloats}, and checks if that raises.
     104        """
     105        self.assertRaises(ValueError, base.Heading.fromFloats, **kw)
     106
     107
     108    def test_badAngleValueEdgeCase(self):
     109        """
     110        Tests that a heading with value C{360.0} fails.
     111        """
     112        self._badValueTest(angleValue=360.0)
     113
     114
     115    def test_badVariationEdgeCase(self):
     116        """
     117        Tests that a variation of C{-180.0} fails.
     118        """
     119        self._badValueTest(variationValue=-180.0)
     120
     121
     122    def test_negativeHeading(self):
     123        """
     124        Tests that negative heading values cause C{ValueError}.
     125        """
     126        self._badValueTest(angleValue=-10.0)
     127
     128
     129    def test_headingTooLarge(self):
     130        """
     131        Tests that an angle value larger than C{360.0} raises C{ValueError}.
     132        """
     133        self._badValueTest(angleValue=370.0)
     134
     135
     136    def test_variationTooNegative(self):
     137        """
     138        Tests that variation values less than C{-180.0} fail.
     139        """
     140        self._badValueTest(variationValue=-190.0)
     141
     142
     143    def test_variationTooPositive(self):
     144        """
     145        Tests that variation values greater than C{-180.0} fail.
     146        """
     147        self._badValueTest(variationValue=190.0)
     148
     149
     150    def test_correctedHeading(self):
     151        """
     152        Simple test for a corrected heading.
     153        """
     154        h = base.Heading.fromFloats(1., variationValue=-10.)
     155        self.assertEquals(h.correctedHeading, base.Angle(11., base.HEADING))
     156
     157
     158    def test_correctedHeadingOverflow(self):
     159        """
     160        Tests that a corrected heading that comes out above 360 degrees is
     161        correctly handled.
     162        """
     163        h = base.Heading.fromFloats(359., variationValue=-2.)
     164        self.assertEquals(h.correctedHeading, base.Angle(1., base.HEADING))
     165
     166
     167    def test_correctedHeadingOverflowEdgeCase(self):
     168        """
     169        Tests that a corrected heading that comes out to exactly 360 degrees
     170        is correctly handled.
     171        """
     172        h = base.Heading.fromFloats(359., variationValue=-1.)
     173        self.assertEquals(h.correctedHeading, base.Angle(0., base.HEADING))
     174
     175
     176    def test_correctedHeadingUnderflow(self):
     177        """
     178        Tests that a corrected heading that comes out under 0 degrees is
     179        correctly handled.
     180        """
     181        h = base.Heading.fromFloats(1., variationValue=2.)
     182        self.assertEquals(h.correctedHeading, base.Angle(359., base.HEADING))
     183
     184
     185    def test_correctedHeadingUnderflowEdgeCase(self):
     186        """
     187        Tests that a corrected heading that comes out under 0 degrees is
     188        correctly handled.
     189        """
     190        h = base.Heading.fromFloats(1., variationValue=1.)
     191        self.assertEquals(h.correctedHeading, base.Angle(0., base.HEADING))
     192
     193
     194    def test_setVariationSign(self):
     195        """
     196        Tests that setting the sign on a variation works.
     197        """
     198        h = base.Heading.fromFloats(1., variationValue=1.)
     199        h.setSign(1)
     200        self.assertEquals(h.variation.inDecimalDegrees, 1.)
     201        h.setSign(-1)
     202        self.assertEquals(h.variation.inDecimalDegrees, -1.)
     203
     204
     205    def test_setBadVariationSign(self):
     206        """
     207        Tests that setting invalid sign values on a variation fails
     208        predictably.
     209        """
     210        h = base.Heading.fromFloats(1., variationValue=1.)
     211        self.assertRaises(ValueError, h.setSign, -50)
     212        self.assertEquals(h.variation.inDecimalDegrees, 1.)
     213
     214        self.assertRaises(ValueError, h.setSign, 0)
     215        self.assertEquals(h.variation.inDecimalDegrees, 1.)
     216
     217        self.assertRaises(ValueError, h.setSign, 50)
     218        self.assertEquals(h.variation.inDecimalDegrees, 1.)
     219
     220
     221    def test_setUnknownVariationSign(self):
     222        """
     223        Tests that setting an otherwise correct sign on an unknown variation
     224        fails predictably.
     225        """
     226        h = base.Heading.fromFloats(1.)
     227        self.assertEquals(None, h.variation.inDecimalDegrees)
     228        self.assertRaises(ValueError, h.setSign, 1)
     229
     230
     231
     232class CoordinateTests(TestCase):
     233    def test_simple(self):
     234        """
     235        Test that coordinates are convertible into a float, and verifies the
     236        generic coordinate repr.
     237        """
     238        value = 10.0
     239        c = base.Coordinate(value)
     240        self.assertEquals(float(c), value)
     241        expectedRepr = "<Angle of unknown type (%s degrees)>" % (value,)
     242        self.assertEquals(repr(c), expectedRepr)
     243
     244
     245    def test_positiveLatitude(self):
     246        """
     247        Tests creating positive latitudes and verifies their repr.
     248        """
     249        value = 50.0
     250        c = base.Coordinate(value, LATITUDE)
     251        self.assertEquals(repr(c), "<Latitude (%s degrees)>" % value)
     252
     253
     254    def test_negativeLatitude(self):
     255        """
     256        Tests creating negative latitudes and verifies their repr.
     257        """
     258        value = -50.0
     259        c = base.Coordinate(value, LATITUDE)
     260        self.assertEquals(repr(c), "<Latitude (%s degrees)>" % value)
     261
     262
     263    def test_positiveLongitude(self):
     264        """
     265        Tests creating positive longitudes and verifies their repr.
     266        """
     267        value = 50.0
     268        c = base.Coordinate(value, LONGITUDE)
     269        self.assertEquals(repr(c), "<Longitude (%s degrees)>" % value)
     270
     271
     272    def test_negativeLongitude(self):
     273        """
     274        Tests creating negative longitudes and verifies their repr.
     275        """
     276        value = -50.0
     277        c = base.Coordinate(value, LONGITUDE)
     278        self.assertEquals(repr(c), "<Longitude (%s degrees)>" % value)
     279
     280
     281    def test_badCoordinateType(self):
     282        """
     283        Tests that creating coordinates with bogus types raises C{ValueError}.
     284        """
     285        self.assertRaises(ValueError, base.Coordinate, 150.0, "BOGUS")
     286
     287
     288    def test_equality(self):
     289        """
     290        Tests that equal coordinates compare equal.
     291        """
     292        self.assertEquals(base.Coordinate(1.0), base.Coordinate(1.0))
     293
     294
     295    def test_differentAnglesInequality(self):
     296        """
     297        Tests that coordinates with different angles compare unequal.
     298        """
     299        c1 = base.Coordinate(1.0)
     300        c2 = base.Coordinate(-1.0)
     301        self.assertNotEquals(c1, c2)
     302
     303
     304    def test_differentTypesInequality(self):
     305        """
     306        Tests that coordinates with the same angles but different types
     307        compare unequal.
     308        """
     309        c1 = base.Coordinate(1.0, LATITUDE)
     310        c2 = base.Coordinate(1.0, LONGITUDE)
     311        self.assertNotEquals(c1, c2)
     312
     313
     314    def test_sign(self):
     315        """
     316        Tests that setting the sign on a coordinate works.
     317        """
     318        c = base.Coordinate(50., LATITUDE)
     319        c.setSign(1)
     320        self.assertEquals(c.inDecimalDegrees, 50.)
     321        c.setSign(-1)
     322        self.assertEquals(c.inDecimalDegrees, -50.)
     323
     324
     325    def test_badVariationSign(self):
     326        """
     327        Tests that setting a bogus sign value on a coordinate raises
     328        C{ValueError} and doesn't affect the coordinate.
     329        """
     330        value = 50.0
     331        c = base.Coordinate(value, LATITUDE)
     332
     333        self.assertRaises(ValueError, c.setSign, -50)
     334        self.assertEquals(c.inDecimalDegrees, 50.)
     335
     336        self.assertRaises(ValueError, c.setSign, 0)
     337        self.assertEquals(c.inDecimalDegrees, 50.)
     338
     339        self.assertRaises(ValueError, c.setSign, 50)
     340        self.assertEquals(c.inDecimalDegrees, 50.)
     341
     342
     343    def test_hemispheres(self):
     344        """
     345        Checks that coordinates know which hemisphere they're in.
     346        """
     347        coordinatesAndHemispheres = [
     348            (base.Coordinate(1.0, LATITUDE), NORTH),
     349            (base.Coordinate(-1.0, LATITUDE), SOUTH),
     350            (base.Coordinate(1.0, LONGITUDE), EAST),
     351            (base.Coordinate(-1.0, LONGITUDE), WEST),
     352        ]
     353
     354        for coordinate, expectedHemisphere in coordinatesAndHemispheres:
     355            self.assertEquals(expectedHemisphere, coordinate.hemisphere)
     356
     357
     358    def test_badHemisphere(self):
     359        """
     360        Checks that asking for a hemisphere when the coordinate doesn't know
     361        raises C{ValueError}.
     362        """
     363        c = base.Coordinate(1.0, None)
     364        self.assertRaises(ValueError, lambda: c.hemisphere)
     365
     366
     367    def test_badLatitudeValues(self):
     368        """
     369        Tests that latitudes outside of M{-90.0 < latitude < 90.0} raise
     370        C{ValueError}.
     371        """
     372        self.assertRaises(ValueError, base.Coordinate, 150.0, LATITUDE)
     373        self.assertRaises(ValueError, base.Coordinate, -150.0, LATITUDE)
     374
     375
     376    def test_badLongitudeValues(self):
     377        """
     378        Tests that longitudes outside of M{-180.0 < longitude < 180.0} raise
     379        C{ValueError}.
     380        """
     381        self.assertRaises(ValueError, base.Coordinate, 250.0, LONGITUDE)
     382        self.assertRaises(ValueError, base.Coordinate, -250.0, LONGITUDE)
     383
     384
     385    def test_inDegreesMinutesSeconds(self):
     386        """
     387        Tests accessing coordinate values in degrees, minutes and seconds.
     388        """
     389        c = base.Coordinate(50.5, LATITUDE)
     390        self.assertEquals(c.inDegreesMinutesSeconds, (50, 30, 0))
     391
     392        c = base.Coordinate(50.213, LATITUDE)
     393        self.assertEquals(c.inDegreesMinutesSeconds, (50, 12, 46))
     394
     395
     396    def test_unknownAngleInDegreesMinutesSeconds(self):
     397        """
     398        Tests accessing unknown coordinate values in degrees, minutes
     399        and seconds.
     400        """
     401        c = base.Coordinate(None, None)
     402        self.assertEquals(c.inDegreesMinutesSeconds, None)
     403
     404
     405
     406class AltitudeTests(TestCase):
     407    """
     408    Tests for the L{twisted.positioning.base.Altitude} class.
     409    """
     410    def test_simple(self):
     411        """
     412        Tests basic altitude functionality.
     413        """
     414        a = base.Altitude(1.)
     415        self.assertEquals(float(a), 1.)
     416        self.assertEquals(a.inMeters, 1.)
     417        self.assertEquals(a.inFeet, 1./base.METERS_PER_FOOT)
     418        self.assertEquals(repr(a), "<Altitude (1.0 m)>")
     419
     420
     421    def test_equality(self):
     422        """
     423        Tests that equal altitudes compare equal.
     424        """
     425        a1 = base.Altitude(1.)
     426        a2 = base.Altitude(1.)
     427        self.assertEquals(a1, a2)
     428
     429
     430    def test_inequality(self):
     431        """
     432        Tests that unequal altitudes compare unequal.
     433        """
     434        a1 = base.Altitude(1.)
     435        a2 = base.Altitude(-1.)
     436        self.assertNotEquals(a1, a2)
     437
     438
     439
     440class SpeedTests(TestCase):
     441    """
     442    Tests for the L{twisted.positioning.base.Speed} class.
     443    """
     444    def test_simple(self):
     445        """
     446        Tests basic speed functionality.
     447        """
     448        s = base.Speed(50.0)
     449        self.assertEquals(s.inMetersPerSecond, 50.0)
     450        self.assertEquals(float(s), 50.0)
     451        self.assertEquals(repr(s), "<Speed (50.0 m/s)>")
     452
     453
     454    def test_negativeSpeeds(self):
     455        """
     456        Tests that negative speeds raise C{ValueError}.
     457        """
     458        self.assertRaises(ValueError, base.Speed, -1.0)
     459
     460
     461    def test_inKnots(self):
     462        """
     463        Tests that speeds can be converted into knots correctly.
     464        """
     465        s = base.Speed(1.0)
     466        self.assertEquals(1/base.MPS_PER_KNOT, s.inKnots)
     467
     468
     469    def test_asFloat(self):
     470        """
     471        Tests that speeds can be converted into C{float}s correctly.
     472        """
     473        self.assertEquals(1.0, float(base.Speed(1.0)))
     474
     475
     476
     477class ClimbTests(TestCase):
     478    """
     479    Tests for L{twisted.positioning.base.Climb}.
     480    """
     481    def test_simple(self):
     482        """
     483        Basic functionality for climb objects.
     484        """
     485        s = base.Climb(42.)
     486        self.assertEquals(s.inMetersPerSecond, 42.)
     487        self.assertEquals(float(s), 42.)
     488        self.assertEquals(repr(s), "<Climb (42.0 m/s)>")
     489
     490
     491    def test_negativeClimbs(self):
     492        """
     493        Tests that creating negative climbs works.
     494        """
     495        base.Climb(-42.)
     496
     497
     498    def test_speedInKnots(self):
     499        """
     500        Tests that climbs can be converted into knots correctly.
     501        """
     502        s = base.Climb(1.0)
     503        self.assertEquals(1/base.MPS_PER_KNOT, s.inKnots)
     504
     505
     506    def test_asFloat(self):
     507        """
     508        Tests that speeds can be converted into C{float}s correctly.
     509        """
     510        self.assertEquals(1.0, float(base.Climb(1.0)))
     511
     512
     513
     514class PositionErrorTests(TestCase):
     515    """
     516    Tests for L{twisted.positioning.base.PositionError}.
     517    """
     518    def test_allUnset(self):
     519        """
     520        Tests that creating an empty L{PositionError} works without checking
     521        the invariant.
     522        """
     523        pe = base.PositionError()
     524        for x in (pe.pdop, pe.hdop, pe.vdop):
     525            self.assertEquals(None, x)
     526
     527
     528    def test_allUnsetWithInvariant(self):
     529        """
     530        Tests that creating an empty L{PositionError} works while checking the
     531        invariant.
     532        """
     533        pe = base.PositionError(testInvariant=True)
     534        for x in (pe.pdop, pe.hdop, pe.vdop):
     535            self.assertEquals(None, x)
     536
     537
     538    def test_simpleWithoutInvariant(self):
     539        """
     540        Tests that creating a simple L{PositionError} with just a HDOP without
     541        checking the invariant works.
     542        """
     543        base.PositionError(hdop=1.0)
     544
     545
     546    def test_simpleWithInvariant(self):
     547        """
     548        Tests that creating a simple L{PositionError} with just a HDOP while
     549        checking the invariant works.
     550        """
     551        base.PositionError(hdop=1.0, testInvariant=True)
     552
     553
     554    def test_invalidWithoutInvariant(self):
     555        """
     556        Tests that creating a simple L{PositionError} with all values set
     557        without checking the invariant works.
     558        """
     559        base.PositionError(pdop=1.0, vdop=1.0, hdop=1.0)
     560
     561
     562    def test_invalidWithInvariant(self):
     563        """
     564        Tests that creating a simple L{PositionError} with all values set to
     565        inconsistent values while checking the invariant raises C{ValueError}.
     566        """
     567        self.assertRaises(ValueError, base.PositionError,
     568                          pdop=1.0, vdop=1.0, hdop=1.0, testInvariant=True)
     569
     570
     571    def test_setDOPWithoutInvariant(self):
     572        """
     573        Tests that setting the PDOP value (with HDOP and VDOP already known)
     574        to an inconsistent value without checking the invariant works.
     575        """
     576        pe = base.PositionError(hdop=1.0, vdop=1.0)
     577        pe.pdop = 100.0
     578        self.assertEquals(pe.pdop, 100.0)
     579
     580
     581    def test_setDOPWithInvariant(self):
     582        """
     583        Tests that setting the PDOP value (with HDOP and VDOP already known)
     584        to an inconsistent value while checking the invariant raises
     585        C{ValueError}.
     586        """
     587        pe = base.PositionError(hdop=1.0, vdop=1.0, testInvariant=True)
     588        pdop = pe.pdop
     589
     590        def setPDOP(pe):
     591            pe.pdop = 100.0
     592
     593        self.assertRaises(ValueError, setPDOP, pe)
     594        self.assertEqual(pe.pdop, pdop)
     595
     596
     597    REPR_TEMPLATE = "<PositionError (pdop: %s, hdop: %s, vdop: %s)>"
     598
     599
     600    def _testDOP(self, pe, pdop, hdop, vdop):
     601        """
     602        Tests the DOP values in a position error, and the repr of that
     603        position error.
     604        """
     605        self.assertEquals(pe.pdop, pdop)
     606        self.assertEquals(pe.hdop, hdop)
     607        self.assertEquals(pe.vdop, vdop)
     608        self.assertEquals(repr(pe), self.REPR_TEMPLATE % (pdop, hdop, vdop))
     609
     610
     611    def test_positionAndHorizontalSet(self):
     612        """
     613        Tests that the VDOP is correctly determined from PDOP and HDOP.
     614        """
     615        pdop, hdop = 2.0, 1.0
     616        vdop = (pdop**2 - hdop**2)**.5
     617        pe = base.PositionError(pdop=pdop, hdop=hdop)
     618        self._testDOP(pe, pdop, hdop, vdop)
     619
     620
     621    def test_positionAndVerticalSet(self):
     622        """
     623        Tests that the HDOP is correctly determined from PDOP and VDOP.
     624        """
     625        pdop, vdop = 2.0, 1.0
     626        hdop = (pdop**2 - vdop**2)**.5
     627        pe = base.PositionError(pdop=pdop, vdop=vdop)
     628        self._testDOP(pe, pdop, hdop, vdop)
     629
     630
     631    def test_horizontalAndVerticalSet(self):
     632        """
     633        Tests that the PDOP is correctly determined from HDOP and VDOP.
     634        """
     635        hdop, vdop = 1.0, 1.0
     636        pdop = (hdop**2 + vdop**2)**.5
     637        pe = base.PositionError(hdop=hdop, vdop=vdop)
     638        self._testDOP(pe, pdop, hdop, vdop)
     639
     640
     641
     642class BeaconInformationTests(TestCase):
     643    """
     644    Tests for L{twisted.positioning.base.BeaconInformation}.
     645    """
     646    def test_minimal(self):
     647        """
     648        Tests some basic features of a minimal beacon information object.
     649
     650        Tests the number of used beacons is zero, the total number of
     651        beacons (the number of seen beacons) is zero, and the repr of
     652        the object.
     653        """
     654        bi = base.BeaconInformation()
     655        self.assertEquals(len(list(bi.usedBeacons)), 0)
     656        self.assertEquals(len(list(bi)), 0)
     657        self.assertEquals(repr(bi),
     658            "<BeaconInformation (seen: 0, used: 0, beacons: {})>")
     659
     660
     661    satelliteKwargs = {"azimuth": 1, "elevation": 1, "signalToNoiseRatio": 1.}
     662
     663
     664    def test_simple(self):
     665        """
     666        Tests a beacon information with a bunch of satellites, none of
     667        which used in computing a fix.
     668        """
     669        def _buildSatellite(**kw):
     670            kwargs = dict(self.satelliteKwargs)
     671            kwargs.update(kw)
     672            return base.Satellite(isUsed=None, **kwargs)
     673
     674        beacons = set()
     675        for prn in range(1, 10):
     676            beacons.add(_buildSatellite(identifier=prn))
     677
     678        bi = base.BeaconInformation(beacons)
     679
     680        self.assertEquals(len(list(bi.usedBeacons)), 0)
     681        self.assertEquals(bi.used, None)
     682        self.assertEquals(len(list(bi)), 9)
     683        self.assertEquals(repr(bi),
     684            "<BeaconInformation (seen: 9, used: ?, beacons: {"
     685            "<Satellite (1), azimuth: 1, elevation: 1, snr: 1.0, used: ?>, "
     686            "<Satellite (2), azimuth: 1, elevation: 1, snr: 1.0, used: ?>, "
     687            "<Satellite (3), azimuth: 1, elevation: 1, snr: 1.0, used: ?>, "
     688            "<Satellite (4), azimuth: 1, elevation: 1, snr: 1.0, used: ?>, "
     689            "<Satellite (5), azimuth: 1, elevation: 1, snr: 1.0, used: ?>, "
     690            "<Satellite (6), azimuth: 1, elevation: 1, snr: 1.0, used: ?>, "
     691            "<Satellite (7), azimuth: 1, elevation: 1, snr: 1.0, used: ?>, "
     692            "<Satellite (8), azimuth: 1, elevation: 1, snr: 1.0, used: ?>, "
     693            "<Satellite (9), azimuth: 1, elevation: 1, snr: 1.0, used: ?>"
     694            "})>")
     695
     696
     697    def test_someSatellitesUsed(self):
     698        """
     699        Tests a beacon information with a bunch of satellites, some of
     700        them used in computing a fix.
     701        """
     702        def _buildSatellite(**kw):
     703            kwargs = dict(self.satelliteKwargs)
     704            kwargs.update(kw)
     705            return base.Satellite(**kwargs)
     706
     707        beacons = set()
     708        for prn in range(1, 10):
     709            isUsed = bool(prn % 2)
     710            satellite = _buildSatellite(identifier=prn, isUsed=isUsed)
     711            beacons.add(satellite)
     712
     713        bi = base.BeaconInformation(beacons)
     714
     715        self.assertEquals(len(list(bi.usedBeacons)), 5)
     716        self.assertEquals(bi.used, 5)
     717        self.assertEquals(len(list(bi)), 9)
     718        self.assertEquals(len(bi.beacons), 9)
     719        self.assertEquals(bi.seen, 9)
     720        self.assertEquals(repr(bi),
     721            "<BeaconInformation (seen: 9, used: 5, beacons: {"
     722            "<Satellite (1), azimuth: 1, elevation: 1, snr: 1.0, used: Y>, "
     723            "<Satellite (2), azimuth: 1, elevation: 1, snr: 1.0, used: N>, "
     724            "<Satellite (3), azimuth: 1, elevation: 1, snr: 1.0, used: Y>, "
     725            "<Satellite (4), azimuth: 1, elevation: 1, snr: 1.0, used: N>, "
     726            "<Satellite (5), azimuth: 1, elevation: 1, snr: 1.0, used: Y>, "
     727            "<Satellite (6), azimuth: 1, elevation: 1, snr: 1.0, used: N>, "
     728            "<Satellite (7), azimuth: 1, elevation: 1, snr: 1.0, used: Y>, "
     729            "<Satellite (8), azimuth: 1, elevation: 1, snr: 1.0, used: N>, "
     730            "<Satellite (9), azimuth: 1, elevation: 1, snr: 1.0, used: Y>"
     731            "})>")
     732
     733
     734
     735class PositioningBeaconTests(TestCase):
     736    """
     737    Tests for L{twisted.positioning.base.PositioningBeacon}.
     738    """
     739    def test_usedRepr(self):
     740        """
     741        Tests the repr of a positioning beacon being used.
     742        """
     743        s = base.PositioningBeacon("A", True)
     744        self.assertEquals(repr(s), "<Beacon (identifier: A, used: Y)>")
     745
     746
     747    def test_unusedRepr(self):
     748        """
     749        Tests the repr of a positioning beacon not being used.
     750        """
     751        s = base.PositioningBeacon("A", False)
     752        self.assertEquals(repr(s), "<Beacon (identifier: A, used: N)>")
     753
     754
     755    def test_dontKnowIfUsed(self):
     756        """
     757        Tests the repr of a positioning beacon that might be used.
     758        """
     759        s = base.PositioningBeacon("A", None)
     760        self.assertEquals(repr(s), "<Beacon (identifier: A, used: ?)>")
     761
     762
     763
     764class SatelliteTests(TestCase):
     765    """
     766    Tests for L{twisted.positioning.base.Satellite}.
     767    """
     768    def test_minimal(self):
     769        """
     770        Tests a minimal satellite that only has a known PRN.
     771
     772        Tests that the azimuth, elevation and signal to noise ratios
     773        are C{None} and verifies the repr.
     774        """
     775        s = base.Satellite(1)
     776        self.assertEquals(s.identifier, 1)
     777        self.assertEquals(s.azimuth, None)
     778        self.assertEquals(s.elevation, None)
     779        self.assertEquals(s.signalToNoiseRatio, None)
     780        self.assertEquals(repr(s), "<Satellite (1), azimuth: ?, "
     781                                   "elevation: ?, snr: ?, used: ?>")
     782
     783
     784    def test_simple(self):
     785        """
     786        Tests a minimal satellite that only has a known PRN.
     787
     788        Tests that the azimuth, elevation and signal to noise ratios
     789        are correct and verifies the repr.
     790        """
     791        s = base.Satellite(identifier=1,
     792                           azimuth=270.,
     793                           elevation=30.,
     794                           signalToNoiseRatio=25.,
     795                           isUsed=True)
     796
     797        self.assertEquals(s.identifier, 1)
     798        self.assertEquals(s.azimuth, 270.)
     799        self.assertEquals(s.elevation, 30.)
     800        self.assertEquals(s.signalToNoiseRatio, 25.)
     801        self.assertEquals(repr(s), "<Satellite (1), azimuth: 270.0, "
     802                                   "elevation: 30.0, snr: 25.0, used: Y>")
  • new file w/twisted/positioning/test/test_nmea.py

    diff --git c/twisted/positioning/test/test_nmea.py w/twisted/positioning/test/test_nmea.py
    new file mode 100644
    index 0000000..d574740
    - +  
     1# Copyright (c) 2009-2011 Twisted Matrix Laboratories.
     2# See LICENSE for details.
     3"""
     4Test cases for using NMEA sentences.
     5"""
     6import datetime
     7from zope.interface import implements
     8
     9from twisted.positioning import base, nmea, ipositioning
     10from twisted.trial.unittest import TestCase
     11
     12from twisted.positioning.base import LATITUDE, LONGITUDE
     13
     14# Sample sentences
     15GPGGA = '$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47'
     16GPRMC = '$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,003.1,W*6A'
     17GPGSA = '$GPGSA,A,3,19,28,14,18,27,22,31,39,,,,,1.7,1.0,1.3*34'
     18GPHDT = '$GPHDT,038.005,T*3B'
     19GPGLL = '$GPGLL,4916.45,N,12311.12,W,225444,A*31'
     20GPGLL_PARTIAL = '$GPGLL,3751.65,S,14507.36,E*77'
     21
     22GPGSV_SINGLE = '$GPGSV,1,1,11,03,03,111,00,04,15,270,00,06,01,010,00,,,,*4b'
     23GPGSV_EMPTY_MIDDLE = '$GPGSV,1,1,11,03,03,111,00,,,,,,,,,13,06,292,00*75'
     24GPGSV_SEQ = GPGSV_FIRST, GPGSV_MIDDLE, GPGSV_LAST = """
     25$GPGSV,3,1,11,03,03,111,00,04,15,270,00,06,01,010,00,13,06,292,00*74
     26$GPGSV,3,2,11,14,25,170,00,16,57,208,39,18,67,296,40,19,40,246,00*74
     27$GPGSV,3,3,11,22,42,067,42,24,14,311,43,27,05,244,00,,,,*4D
     28""".split()
     29
     30
     31
     32class NMEATestReceiver(object):
     33    """
     34    An NMEA receiver for testing.
     35
     36    Remembers the last sentence it has received.
     37    """
     38    implements(ipositioning.INMEAReceiver)
     39
     40    def __init__(self):
     41        self.clear()
     42
     43
     44    def clear(self):
     45        """
     46        Forgets the received sentence (if any), by setting
     47        C{self.receivedSentence} to C{None}.
     48        """
     49        self.receivedSentence = None
     50
     51
     52    def sentenceReceived(self, sentence):
     53        self.receivedSentence = sentence
     54
     55
     56
     57class NMEACallbackTestProtocol(nmea.NMEAProtocol):
     58    """
     59    An NMEA protocol with a bunch of callbacks that remembers when
     60    those callbacks have been called.
     61    """
     62    def __init__(self):
     63        nmea.NMEAProtocol.__init__(self, None)
     64
     65        for sentenceType in nmea.NMEAProtocol.SENTENCE_CONTENTS:
     66            self._createCallback(sentenceType)
     67
     68        self.clear()
     69
     70
     71    def clear(self):
     72        """
     73        Forgets all of the called methods, by setting C{self.called} to
     74        C{None}.
     75        """
     76        self.called = {}
     77
     78
     79    SENTENCE_TYPES = list(nmea.NMEAProtocol.SENTENCE_CONTENTS)
     80
     81
     82    def _createCallback(self, sentenceType):
     83        """
     84        Creates a callback for an NMEA sentence.
     85        """
     86        def callback(sentence):
     87            self.called[sentenceType] = True
     88
     89        setattr(self, "nmea_" + sentenceType, callback)
     90
     91
     92
     93class CallbackTests(TestCase):
     94    """
     95    Tests if callbacks on NMEA protocols are correctly called.
     96    """
     97    def setUp(self):
     98        self.callbackProtocol = NMEACallbackTestProtocol()
     99
     100
     101    def test_callbacksCalled(self):
     102        """
     103        Tests that the correct callbacks fire, and that *only* those fire.
     104        """
     105        sentencesByType = {'GPGGA': ['$GPGGA*56'],
     106                           'GPGLL': ['$GPGLL*50'],
     107                           'GPGSA': ['$GPGSA*42'],
     108                           'GPGSV': ['$GPGSV*55'],
     109                           'GPHDT': ['$GPHDT*4f'],
     110                           'GPRMC': ['$GPRMC*4b']}
     111
     112        for calledSentenceType in sentencesByType:
     113            for sentence in sentencesByType[calledSentenceType]:
     114                self.callbackProtocol.lineReceived(sentence)
     115                called = self.callbackProtocol.called
     116
     117                for sentenceType in NMEACallbackTestProtocol.SENTENCE_TYPES:
     118                    if sentenceType == calledSentenceType:
     119                        self.assertEquals(called[sentenceType], True)
     120                    else:
     121                        self.assertNotIn(sentenceType, called)
     122
     123                self.callbackProtocol.clear()
     124
     125
     126
     127class SplitTest(TestCase):
     128    """
     129    Checks splitting of NMEA sentences.
     130    """
     131    def test_withChecksum(self):
     132        """
     133        Tests that an NMEA sentence with a checksum gets split correctly.
     134        """
     135        splitSentence = nmea.split("$GPGGA,spam,eggs*00")
     136        self.assertEqual(splitSentence, ['GPGGA', 'spam', 'eggs'])
     137
     138
     139    def test_noCheckum(self):
     140        """
     141        Tests that an NMEA sentence without a checksum gets split correctly.
     142        """
     143        splitSentence = nmea.split("$GPGGA,spam,eggs*")
     144        self.assertEqual(splitSentence, ['GPGGA', 'spam', 'eggs'])
     145
     146
     147
     148class ChecksumTests(TestCase):
     149    """
     150    NMEA sentence checksum verification tests.
     151    """
     152    def test_valid(self):
     153        """
     154        Tests checkum validation for valid or missing checksums.
     155        """
     156        sentences = [GPGGA, GPGGA[:-2]]
     157
     158        for s in sentences:
     159            nmea.validateChecksum(s)
     160
     161
     162    def test_invalid(self):
     163        """
     164        Tests checksum validation on invalid checksums.
     165        """
     166        bareSentence, checksum = GPGGA.split("*")
     167        badChecksum = "%x" % (int(checksum, 16) + 1)
     168        sentences = ["%s*%s" % (bareSentence, badChecksum)]
     169
     170        for s in sentences:
     171            self.assertRaises(base.InvalidChecksum, nmea.validateChecksum, s)
     172
     173
     174
     175class NMEAReceiverSetup:
     176    """
     177    A mixin for tests that need an NMEA receiver (and a protocol attached to
     178    it).
     179
     180    @ivar receiver: An NMEA receiver that remembers the last sentence.
     181    @type receiver: L{NMEATestReceiver}
     182
     183    @ivar protocol: An NMEA protocol attached to the receiver.
     184    @type protocol: L{twisted.positioning.nmea.NMEAProtocol}
     185    """
     186    def setUp(self):
     187        self.receiver = NMEATestReceiver()
     188        self.protocol = nmea.NMEAProtocol(self.receiver)   
     189
     190
     191
     192class GSVSequenceTests(NMEAReceiverSetup, TestCase):
     193    """
     194    Tests if GSV sentence sequences are identified correctly.
     195    """
     196    def test_firstSentence(self):
     197        """
     198        Tests if the last sentence in a GSV sequence is correctly identified.
     199        """
     200        self.protocol.lineReceived(GPGSV_FIRST)
     201        sentence = self.receiver.receivedSentence
     202
     203        self.assertTrue(sentence._isFirstGSVSentence())
     204        self.assertFalse(sentence._isLastGSVSentence())
     205
     206
     207    def test_middleSentence(self):
     208        """
     209        Tests if a sentence in the middle of a GSV sequence is correctly
     210        identified (as being neither the last nor the first).
     211        """
     212        self.protocol.lineReceived(GPGSV_MIDDLE)
     213        sentence = self.receiver.receivedSentence
     214
     215        self.assertFalse(sentence._isFirstGSVSentence())
     216        self.assertFalse(sentence._isLastGSVSentence())
     217
     218
     219    def test_lastSentence(self):
     220        """
     221        Tests if the last sentence in a GSV sequence is correctly identified.
     222        """
     223        self.protocol.lineReceived(GPGSV_LAST)
     224        sentence = self.receiver.receivedSentence
     225
     226        self.assertFalse(sentence._isFirstGSVSentence())
     227        self.assertTrue(sentence._isLastGSVSentence())
     228
     229
     230
     231class BogusSentenceTests(NMEAReceiverSetup, TestCase):
     232    """
     233    Tests for verifying predictable failure for bogus NMEA sentences.
     234    """
     235    def assertRaisesOnSentence(self, exceptionClass, sentence):
     236        """
     237        Asserts that the protocol raises C{exceptionClass} when it receives
     238        C{sentence}.
     239
     240        @param exceptionClass: The exception class expected to be raised.
     241        @type exceptionClass: C{Exception} subclass
     242
     243        @param sentence: The (bogus) NMEA sentence.
     244        @type sentence: C{str}
     245        """
     246        self.assertRaises(exceptionClass, self.protocol.lineReceived, sentence)
     247
     248
     249    def test_raiseOnUnknownSentenceType(self):
     250        """
     251        Tests that the protocol raises C{ValueError} when you feed it a
     252        well-formed sentence of unknown type.
     253        """
     254        self.assertRaisesOnSentence(ValueError, "$GPBOGUS*5b")
     255
     256
     257    def test_raiseOnMalformedSentences(self):
     258        """
     259        Tests that the protocol raises L{base.InvalidSentence} when you feed
     260        it a malformed sentence.
     261        """
     262        self.assertRaisesOnSentence(base.InvalidSentence, "GPBOGUS")
     263
     264
     265
     266class NMEASentenceTests(NMEAReceiverSetup, TestCase):
     267    """
     268    Tests for L{nmea.NMEASentence} objects.
     269    """
     270    def test_repr(self):
     271        """
     272        Checks that the C{repr} of L{nmea.NMEASentence} objects is
     273        predictable.
     274        """
     275        sentencesWithExpectedRepr = [
     276            (GPGSA,
     277             "<NMEASentence (GPGSA) {"
     278             "dataMode: A, "
     279             "fixType: 3, "
     280             "horizontalDilutionOfPrecision: 1.0, "
     281             "positionDilutionOfPrecision: 1.7, "
     282             "usedSatellitePRN_0: 19, "
     283             "usedSatellitePRN_1: 28, "
     284             "usedSatellitePRN_2: 14, "
     285             "usedSatellitePRN_3: 18, "
     286             "usedSatellitePRN_4: 27, "
     287             "usedSatellitePRN_5: 22, "
     288             "usedSatellitePRN_6: 31, "
     289             "usedSatellitePRN_7: 39, "
     290             "verticalDilutionOfPrecision: 1.3"
     291             "}>"),
     292        ]
     293
     294        for sentence, repr_ in sentencesWithExpectedRepr:
     295            self.protocol.lineReceived(sentence)
     296            received = self.receiver.receivedSentence
     297            self.assertEquals(repr(received), repr_)
     298
     299
     300
     301class ParsingTests(NMEAReceiverSetup, TestCase):
     302    """
     303    Tests if raw NMEA sentences get parsed correctly.
     304
     305    This doesn't really involve any interpretation, just turning ugly raw NMEA
     306    representations into objects that are more pleasant to work with.
     307    """
     308    def _parserTest(self, sentence, expected):
     309        """
     310        Passes a sentence to the protocol and gets the parsed sentence from
     311        the receiver. Then verifies that the parsed sentence contains the
     312        expected data.
     313        """
     314        self.protocol.lineReceived(sentence)
     315        received = self.receiver.receivedSentence
     316        self.assertEquals(expected, received._sentenceData)
     317
     318
     319    def test_fullRMC(self):
     320        """
     321        Tests that a full RMC sentence is correctly parsed.
     322        """
     323        expected = {
     324             'type': 'GPRMC',
     325             'latitudeFloat': '4807.038',
     326             'latitudeHemisphere': 'N',
     327             'longitudeFloat': '01131.000',
     328             'longitudeHemisphere': 'E',
     329             'magneticVariation': '003.1',
     330             'magneticVariationDirection': 'W',
     331             'speedInKnots': '022.4',
     332             'timestamp': '123519',
     333             'datestamp': '230394',
     334             'trueHeading': '084.4',
     335             'dataMode': 'A',
     336        }
     337        self._parserTest(GPRMC, expected)
     338
     339
     340    def test_fullGGA(self):
     341        """
     342        Tests that a full GGA sentence is correctly parsed.
     343        """
     344        expected = {
     345            'type': 'GPGGA',
     346
     347            'altitude': '545.4',
     348            'altitudeUnits': 'M',
     349            'heightOfGeoidAboveWGS84': '46.9',
     350            'heightOfGeoidAboveWGS84Units': 'M',
     351
     352            'horizontalDilutionOfPrecision': '0.9',
     353
     354            'latitudeFloat': '4807.038',
     355            'latitudeHemisphere': 'N',
     356            'longitudeFloat': '01131.000',
     357            'longitudeHemisphere': 'E',
     358
     359            'numberOfSatellitesSeen': '08',
     360            'timestamp': '123519',
     361            'fixQuality': '1',
     362        }
     363        self._parserTest(GPGGA, expected)
     364
     365
     366    def test_fullGLL(self):
     367        """
     368        Tests that a full GLL sentence is correctly parsed.
     369        """
     370        expected = {
     371            'type': 'GPGLL',
     372
     373            'latitudeFloat': '4916.45',
     374            'latitudeHemisphere': 'N',
     375            'longitudeFloat': '12311.12',
     376            'longitudeHemisphere': 'W',
     377
     378            'timestamp': '225444',
     379            'dataMode': 'A',
     380        }
     381        self._parserTest(GPGLL, expected)
     382
     383
     384    def test_partialGLL(self):
     385        """
     386        Tests that a partial GLL sentence is correctly parsed.
     387        """
     388        expected = {
     389            'type': 'GPGLL',
     390
     391            'latitudeFloat': '3751.65',
     392            'latitudeHemisphere': 'S',
     393            'longitudeFloat': '14507.36',
     394            'longitudeHemisphere': 'E',
     395        }
     396        self._parserTest(GPGLL_PARTIAL, expected)
     397
     398
     399    def test_fullGSV(self):
     400        """
     401        Tests that a full GSV sentence is correctly parsed.
     402        """
     403        expected = {
     404            'type': 'GPGSV',
     405            'GSVSentenceIndex': '1',
     406            'numberOfGSVSentences': '3',
     407            'numberOfSatellitesSeen': '11',
     408
     409            'azimuth_0': '111',
     410            'azimuth_1': '270',
     411            'azimuth_2': '010',
     412            'azimuth_3': '292',
     413
     414            'elevation_0': '03',
     415            'elevation_1': '15',
     416            'elevation_2': '01',
     417            'elevation_3': '06',
     418
     419            'satellitePRN_0': '03',
     420            'satellitePRN_1': '04',
     421            'satellitePRN_2': '06',
     422            'satellitePRN_3': '13',
     423
     424            'signalToNoiseRatio_0': '00',
     425            'signalToNoiseRatio_1': '00',
     426            'signalToNoiseRatio_2': '00',
     427            'signalToNoiseRatio_3': '00',
     428        }
     429        self._parserTest(GPGSV_FIRST, expected)
     430
     431
     432    def test_partialGSV(self):
     433        """
     434        Tests that a partial GSV sentence is correctly parsed.
     435        """
     436        expected = {
     437            'type': 'GPGSV',
     438            'GSVSentenceIndex': '3',
     439            'numberOfGSVSentences': '3',
     440            'numberOfSatellitesSeen': '11',
     441
     442            'azimuth_0': '067',
     443            'azimuth_1': '311',
     444            'azimuth_2': '244',
     445
     446            'elevation_0': '42',
     447            'elevation_1': '14',
     448            'elevation_2': '05',
     449
     450            'satellitePRN_0': '22',
     451            'satellitePRN_1': '24',
     452            'satellitePRN_2': '27',
     453
     454            'signalToNoiseRatio_0': '42',
     455            'signalToNoiseRatio_1': '43',
     456            'signalToNoiseRatio_2': '00',
     457        }
     458        self._parserTest(GPGSV_LAST, expected)
     459
     460
     461    def test_fullHDT(self):
     462        """
     463        Tests that a full HDT sentence is correctly parsed.
     464        """
     465        expected = {
     466            'type': 'GPHDT',
     467            'trueHeading': '038.005',
     468        }
     469        self._parserTest(GPHDT, expected)
     470
     471
     472    def test_typicalGSA(self):
     473        """
     474        Tests that a typical GSA sentence is correctly parsed.
     475        """
     476        expected = {
     477            'type': 'GPGSA',
     478
     479            'dataMode': 'A',
     480            'fixType': '3',
     481
     482            'usedSatellitePRN_0': '19',
     483            'usedSatellitePRN_1': '28',
     484            'usedSatellitePRN_2': '14',
     485            'usedSatellitePRN_3': '18',
     486            'usedSatellitePRN_4': '27',
     487            'usedSatellitePRN_5': '22',
     488            'usedSatellitePRN_6': '31',
     489            'usedSatellitePRN_7': '39',
     490
     491            'positionDilutionOfPrecision': '1.7',
     492            'horizontalDilutionOfPrecision': '1.0',
     493            'verticalDilutionOfPrecision': '1.3',
     494        }
     495        self._parserTest(GPGSA, expected)
     496
     497
     498
     499class FixerTestMixin:
     500    """
     501    Mixin for tests for the fixers on L{nmea.NMEAAdapter} that adapt
     502    from NMEA-specific notations to generic Python objects.
     503
     504    @ivar adapter: The NMEA adapter.
     505    @type adapter: L{nmea.NMEAAdapter}
     506    """
     507    def setUp(self):
     508        self.adapter = nmea.NMEAAdapter(base.BasePositioningReceiver())
     509
     510
     511    def _fixerTest(self, sentenceData, expected=None, exceptionClass=None):
     512        """
     513        A generic adapter fixer test.
     514
     515        Creates a sentence from the C{sentenceData} and sends that to the
     516        adapter. If C{exceptionClass} is not passed, this is assumed to work,
     517        and C{expected} is compared with the adapter's internal state.
     518        Otherwise, passing the sentence to the adapter is checked to raise
     519        C{exceptionClass}.
     520
     521        @param sentenceData: Raw sentence content.
     522        @type sentenceData: C{dict} mapping C{str} to C{str}
     523
     524        @param expected: The expected state of the adapter.
     525        @type expected: C{dict} or C{None}
     526
     527        @param exceptionClass: The exception to be raised by the adapter.
     528        @type exceptionClass: subclass of C{Exception}
     529        """
     530        sentence = nmea.NMEASentence(sentenceData)
     531        def receiveSentence():
     532            self.adapter.sentenceReceived(sentence)
     533
     534        if exceptionClass is None:
     535            receiveSentence()
     536            self.assertEquals(self.adapter._state, expected)
     537        else:
     538            self.assertRaises(exceptionClass, receiveSentence)
     539
     540        self.adapter.clear()
     541
     542
     543
     544class TimestampFixerTests(FixerTestMixin, TestCase):
     545    """
     546    Tests conversion from NMEA timestamps to C{datetime.time} objects.
     547    """
     548    def test_simple(self):
     549        """
     550        Tests that a simple timestamp is converted correctly.
     551        """
     552        data = {'timestamp': '123456'} # 12:34:56Z
     553        expected = {'_time': datetime.time(12, 34, 56)}
     554        self._fixerTest(data, expected)
     555
     556
     557    def test_broken(self):
     558        """
     559        Tests that a broken timestamp raises C{ValueError}.
     560        """
     561        badTimestamps = '993456', '129956', '123499'
     562
     563        for t in badTimestamps:
     564            self._fixerTest({'timestamp': t}, exceptionClass=ValueError)
     565
     566
     567
     568class DatestampFixerTests(FixerTestMixin, TestCase):
     569    def test_intelligent(self):
     570        """
     571        Tests "intelligent" datestamp handling (guess century based on last
     572        two digits). Also tests that this is the default.
     573        """
     574        self.assertEqual(self.adapter.DATESTAMP_HANDLING,
     575                         self.adapter.INTELLIGENT_DATESTAMPS)
     576
     577        datestring, date = '010199', datetime.date(1999, 1, 1)
     578        self._fixerTest({'datestamp': datestring}, {'_date': date})
     579
     580        datestring, date = '010109', datetime.date(2009, 1, 1)
     581        self._fixerTest({'datestamp': datestring}, {'_date': date})
     582
     583
     584    def test_19xx(self):
     585        """
     586        Tests 20th-century-only datestam handling method.
     587        """
     588        self.adapter.DATESTAMP_HANDLING = self.adapter.DATESTAMPS_FROM_19XX
     589
     590        datestring, date = '010199', datetime.date(1999, 1, 1)
     591        self._fixerTest({'datestamp': datestring}, {'_date': date})
     592
     593        datestring, date = '010109', datetime.date(1909, 1, 1)
     594        self._fixerTest({'datestamp': datestring}, {'_date': date})
     595
     596
     597    def test_20xx(self):
     598        """
     599        Tests 21st-century-only datestam handling method.
     600        """
     601        self.adapter.DATESTAMP_HANDLING = self.adapter.DATESTAMPS_FROM_20XX
     602
     603        datestring, date = '010199', datetime.date(2099, 1, 1)
     604        self._fixerTest({'datestamp': datestring}, {'_date': date})
     605
     606        datestring, date = '010109', datetime.date(2009, 1, 1)
     607        self._fixerTest({'datestamp': datestring}, {'_date': date})
     608
     609
     610    def test_bogusMethod(self):
     611        """
     612        Tests that using a nonexistent datestamp handling method raises C{ValueError}.
     613        """
     614        self.adapter.DATESTAMP_HANDLING = "BOGUS_VALUE"
     615        self._fixerTest({'datestamp': '010199'}, exceptionClass=ValueError)
     616
     617
     618    def test_broken(self):
     619        """
     620        Tests that a broken datestring raises C{ValueError}.
     621        """
     622        self._fixerTest({'datestamp': '123456'}, exceptionClass=ValueError)
     623
     624
     625
     626def _nmeaFloat(degrees, minutes):
     627    """
     628    Builds an NMEA float representation for a given angle in degrees and
     629    decimal minutes.
     630
     631    @param degrees: The integer degrees for this angle.
     632    @type degrees: C{int}
     633    @param minutes: The decimal minutes value for this angle.
     634    @type minutes: C{float}
     635    @return: The NMEA float representation for this angle.
     636    @rtype: C{str}
     637    """
     638    return "%i%0.3f" % (degrees, minutes)
     639
     640
     641def _coordinateSign(hemisphere):
     642    """
     643    Return the sign of a coordinate.
     644
     645    This is C{1} if the coordinate is in the northern or eastern hemispheres,
     646    C{-1} otherwise.
     647
     648    @param hemisphere: NMEA shorthand for the hemisphere. One of "NESW".
     649    @type hemisphere: C{str}
     650
     651    @return: The sign of the coordinate value.
     652    @rtype: C{int}
     653    """
     654    return 1 if hemisphere in "NE" else -1
     655
     656
     657def _coordinateType(hemisphere):
     658    """
     659    Return the type of a coordinate.
     660
     661    This is L{LATITUDE} if the coordinate is in the northern or southern
     662    hemispheres, L{LONGITUDE} otherwise.
     663
     664    @param hemisphere: NMEA shorthand for the hemisphere. One of "NESW".
     665    @type hemisphere: C{str}
     666
     667    @return: The type of the coordinate (L{LATITUDE} or L{LONGITUDE})
     668    """
     669    return LATITUDE if hemisphere in "NS" else LONGITUDE
     670
     671
     672
     673class CoordinateFixerTests(FixerTestMixin, TestCase):
     674    """
     675    Tests turning NMEA coordinate notations into something more pleasant.
     676    """
     677    def _coordinateFixerTest(self, degrees, minutes, hemisphere):
     678        """
     679        Tests that an NMEA representation of a coordinate at the given
     680        location converts correctly into a L{base.Coordinate}.
     681        """
     682        coordinateType = _coordinateType(hemisphere)
     683        if coordinateType is LATITUDE:
     684            typeName = "latitude"
     685        else:
     686            typeName = "longitude"
     687
     688        sentenceData = {"%sFloat" % typeName: _nmeaFloat(degrees, minutes),
     689                        "%sHemisphere" % typeName: hemisphere}
     690
     691        coordinateValue = _coordinateSign(hemisphere)*(degrees + minutes/60)
     692        coordinate = base.Coordinate(coordinateValue, coordinateType)
     693
     694        self._fixerTest(sentenceData, {typeName: coordinate})
     695
     696
     697    def test_north(self):
     698        """
     699        Tests that NMEA coordinate representations in the northern hemisphere
     700        convert correctly.
     701        """
     702        self._coordinateFixerTest(10, 30.0, "N")
     703
     704
     705    def test_south(self):
     706        """
     707        Tests that NMEA coordinate representations in the southern hemisphere
     708        convert correctly.
     709        """
     710        self._coordinateFixerTest(45, 12.145, "S")
     711
     712
     713    def test_east(self):
     714        """
     715        Tests that NMEA coordinate representations in the eastern hemisphere
     716        convert correctly.
     717        """
     718        self._coordinateFixerTest(53, 31.513, "E")
     719
     720
     721    def test_west(self):
     722        """
     723        Tests that NMEA coordinate representations in the western hemisphere
     724        convert correctly.
     725        """
     726        self._coordinateFixerTest(12, 45.120, "W")
     727
     728
     729    def test_badHemisphere(self):
     730        """
     731        Tests that NMEA coordinate representations for nonexistent hemispheres
     732        raise C{ValueError} when you attempt to parse them.
     733        """
     734        sentenceData = {'longitudeHemisphere': 'Q'}
     735        self._fixerTest(sentenceData, exceptionClass=ValueError)
     736
     737
     738    def test_badHemisphereSign(self):
     739        """
     740        Tests that NMEA coordinate repesentation parsing fails predictably
     741        when you pass nonexistent coordinate types (not latitude or
     742        longitude).
     743        """
     744        getSign = lambda: self.adapter._getHemisphereSign("BOGUS_VALUE")
     745        self.assertRaises(ValueError, getSign)
     746
     747
     748
     749class AltitudeFixerTests(FixerTestMixin, TestCase):
     750    """
     751    Tests that NMEA representations of altitudes are correctly converted.
     752    """
     753    def test_fixAltitude(self):
     754        """
     755        Tests that the NMEA representation of an altitude (above mean sea
     756        level) is correctly converted.
     757        """
     758        key, value = 'altitude', '545.4'
     759        altitude = base.Altitude(float(value))
     760        self._fixerTest({key: value}, {key: altitude})
     761
     762
     763    def test_heightOfGeoidAboveWGS84(self):
     764        """
     765        Tests that the NMEA representation of an altitude of the geoid (above
     766        the WGS84 reference level) is correctly converted.
     767        """
     768        key, value = 'heightOfGeoidAboveWGS84', '46.9'
     769        altitude = base.Altitude(float(value))
     770        self._fixerTest({key: value}, {key: altitude})
     771
     772
     773
     774class SpeedFixerTests(FixerTestMixin, TestCase):
     775    """
     776    Tests that NMEA representations of speeds are correctly converted.
     777    """
     778    def test_speedInKnots(self):
     779        """
     780        Tests if speeds reported in knots correctly get converted to
     781        meters per second.
     782        """
     783        key, value, targetKey = "speedInKnots", "10", "speed"
     784        speed = base.Speed(float(value) * base.MPS_PER_KNOT)
     785        self._fixerTest({key: value}, {targetKey: speed})
     786
     787
     788
     789class VariationFixerTests(FixerTestMixin, TestCase):
     790    """
     791    Tests if the absolute values of magnetic variations on the heading
     792    and their sign get combined correctly, and if that value gets
     793    combined with a heading correctly.
     794    """
     795    def test_west(self):
     796        """
     797        Tests westward (negative) magnetic variation.
     798        """
     799        variation, direction = "1.34", "W"
     800        heading = base.Heading.fromFloats(variationValue=-1*float(variation))
     801        sentenceData = {'magneticVariation': variation,
     802                        'magneticVariationDirection': direction}
     803
     804        self._fixerTest(sentenceData, {'heading': heading})
     805
     806
     807    def test_east(self):
     808        """
     809        Tests eastward (positive) magnetic variation.
     810        """
     811        variation, direction = "1.34", "E"
     812        heading = base.Heading.fromFloats(variationValue=float(variation))
     813        sentenceData = {'magneticVariation': variation,
     814                        'magneticVariationDirection': direction}
     815
     816        self._fixerTest(sentenceData, {'heading': heading})
     817
     818
     819    def test_withHeading(self):
     820        """
     821        Tests if variation values get combined with headings correctly.
     822        """
     823        trueHeading, variation, direction = "123.12", "1.34", "E"
     824        sentenceData = {'trueHeading': trueHeading,
     825                        'magneticVariation': variation,
     826                        'magneticVariationDirection': direction}
     827        heading = base.Heading.fromFloats(float(trueHeading),
     828                                          variationValue=float(variation))
     829        self._fixerTest(sentenceData, {'heading': heading})
     830
     831
     832
     833class PositionErrorFixerTests(FixerTestMixin, TestCase):
     834    """
     835    Position errors in NMEA are passed as dilutions of precision (DOP). This
     836    is a measure relative to some specified value of the GPS device as its
     837    "reference" precision. Unfortunately, there are very few ways of figuring
     838    this out from just the device (sans manual).
     839
     840    There are two basic DOP values: vertical and horizontal. HDOP tells you
     841    how precise your location is on the face of the earth (pretending it's
     842    flat, at least locally). VDOP tells you how precise your altitude is
     843    known. PDOP (position DOP) is a dependent value defined as the Nuclidean
     844    norm of those two, and gives you a more generic "goodness of fix" value.
     845    """
     846    def test_simple(self):
     847        self._fixerTest(
     848            {'horizontalDilutionOfPrecision': '11'},
     849            {'positionError': base.PositionError(hdop=11.)})
     850
     851
     852    def test_mixing(self):
     853        pdop, hdop, vdop = "1", "1", "1"
     854        positionError = base.PositionError(pdop=float(pdop),
     855                                           hdop=float(hdop),
     856                                           vdop=float(vdop))
     857        sentenceData = {'positionDilutionOfPrecision': pdop,
     858                        'horizontalDilutionOfPrecision': hdop,
     859                        'verticalDilutionOfPrecision': vdop}
     860        self._fixerTest(sentenceData, {"positionError": positionError})
     861
     862
     863class ValidFixTests(FixerTestMixin, TestCase):
     864    """
     865    Tests that data reported from a valid fix is used.
     866    """
     867    def test_GGA(self):
     868        """
     869        Tests that GGA data with a valid fix is used.
     870        """
     871        sentenceData = {'type': 'GPGGA',
     872                        'altitude': '545.4',
     873                        'fixQuality': nmea.GGA_GPS_FIX}
     874        expectedState = {'altitude': base.Altitude(545.4)}
     875
     876        self._fixerTest(sentenceData, expectedState)
     877
     878
     879    def test_GLL(self):
     880        """
     881        Tests that GLL data with a valid data mode is used.
     882        """
     883        sentenceData = {'type': 'GPGLL',
     884                        'altitude': '545.4',
     885                        'dataMode': nmea.DATA_ACTIVE}
     886        expectedState = {'altitude': base.Altitude(545.4)}
     887
     888        self._fixerTest(sentenceData, expectedState)
     889
     890
     891
     892class InvalidFixTests(FixerTestMixin, TestCase):
     893    """
     894    Tests that data being reported from a bad or incomplete fix isn't
     895    used. Although the specification dictates that GPSes shouldn't produce
     896    NMEA sentences with real-looking values for altitude or position in them
     897    unless they have at least some semblance of a GPS fix, this is widely
     898    ignored.
     899    """
     900    def _invalidFixTest(self, sentenceData):
     901        """
     902        Tests that sentences with an invalid fix or data mode result in empty
     903        state (ie, the data isn't used).
     904        """
     905        self._fixerTest(sentenceData, {})
     906
     907
     908    def test_GGA(self):
     909        """
     910        Tests that GGA sentence data is unused when there is no fix.
     911        """
     912        sentenceData = {'type': 'GPGGA',
     913                        'altitude': '545.4',
     914                        'fixQuality': nmea.GGA_INVALID_FIX}
     915
     916        self._invalidFixTest(sentenceData)
     917
     918
     919    def test_GLL(self):
     920        """
     921        Tests that GLL sentence data is unused when the data is flagged as
     922        void.
     923        """
     924        sentenceData = {'type': 'GPGLL',
     925                        'altitude': '545.4',
     926                        'dataMode': nmea.DATA_VOID}
     927
     928        self._invalidFixTest(sentenceData)
     929
     930
     931    def test_badGSADataMode(self):
     932        """
     933        Tests that GSA sentence data is not used when there is no GPS fix, but
     934        the data mode claims the data is "active". Some GPSes do do this,
     935        unfortunately, and that means you shouldn't use the data.
     936        """
     937        sentenceData = {'type': 'GPGSA',
     938                        'altitude': '545.4',
     939                        'dataMode': nmea.DATA_ACTIVE,
     940                        'fixType': nmea.GSA_NO_FIX}
     941        self._invalidFixTest(sentenceData)
     942
     943
     944    def test_badGSAFixType(self):
     945        """
     946        Tests that GSA sentence data is not used when the fix claims to be
     947        valid (albeit only 2D), but the data mode says the data is void. Some
     948        GPSes do do this, unfortunately, and that means you shouldn't use the
     949        data.
     950        """
     951        sentenceData = {'type': 'GPGSA',
     952                        'altitude': '545.4',
     953                        'dataMode': nmea.DATA_VOID,
     954                        'fixType': nmea.GSA_2D_FIX}
     955        self._invalidFixTest(sentenceData)
     956
     957
     958    def test_badGSADataModeAndFixType(self):
     959        """
     960        Tests that GSA sentence data is not use when neither the fix nor the
     961        data mode is any good.
     962        """
     963        sentenceData = {'type': 'GPGSA',
     964                        'altitude': '545.4',
     965                        'dataMode': nmea.DATA_VOID,
     966                        'fixType': nmea.GSA_NO_FIX}
     967        self._invalidFixTest(sentenceData)
     968
     969
     970
     971class MockNMEAReceiver(base.BasePositioningReceiver):
     972    """
     973    A mock NMEA receiver.
     974
     975    Mocks all the L{IPositioningReceiver} methods with stubs that don't do
     976    anything but register that they were called.
     977    """
     978    def __init__(self):
     979        self.clear()
     980
     981        for methodName in ipositioning.IPositioningReceiver:
     982            self._addCallback(methodName)
     983
     984
     985    def clear(self):
     986        """
     987        Forget all the methods that have been called on this receiver, by
     988        emptying C{self.called}.
     989        """
     990        self.called = {}
     991
     992
     993    def _addCallback(self, name):
     994        def callback(*a, **kw):
     995            self.called[name] = True
     996
     997        setattr(self, name, callback)
     998
     999
     1000
     1001class NMEAReceiverTest(TestCase):
     1002    """
     1003    Tests for the NMEA receiver.
     1004    """
     1005    def setUp(self):
     1006        self.receiver = MockNMEAReceiver()
     1007        self.adapter = nmea.NMEAAdapter(self.receiver)
     1008        self.protocol = nmea.NMEAProtocol(self.adapter)
     1009
     1010
     1011    def _receiverTest(self, sentences, expectedFired=(), extraTest=None):
     1012        """
     1013        A generic test for NMEA receiver behavior.
     1014
     1015        @param sentences: The sequence of sentences to simulate receiving.
     1016        @type sentences: iterable of C{str}
     1017        @param expectedFired: The names of the callbacks expected to fire.
     1018        @type expectedFired: iterable of C{str}
     1019        @param extraTest: An optional extra test hook.
     1020        @type extraTest: nullary callable
     1021        """
     1022        for sentence in sentences:
     1023            self.protocol.lineReceived(sentence)
     1024
     1025        actuallyFired = self.receiver.called.keys()
     1026        self.assertEquals(set(actuallyFired), set(expectedFired))
     1027
     1028        if extraTest is not None:
     1029            extraTest()
     1030
     1031        self.receiver.clear()
     1032        self.adapter.clear()
     1033
     1034
     1035    def test_positionErrorUpdateAcrossStates(self):
     1036        """
     1037        Tests that the positioning error is updated across multiple states.
     1038        """
     1039        sentences = [GPGSA] + GPGSV_SEQ
     1040        callbacksFired = ['positionErrorReceived', 'beaconInformationReceived']
     1041
     1042        def checkBeaconInformation():
     1043            beaconInformation = self.adapter._state['beaconInformation']
     1044            self.assertEqual(beaconInformation.seen, 11)
     1045            self.assertEqual(beaconInformation.used, 5)
     1046
     1047        self._receiverTest(sentences, callbacksFired, checkBeaconInformation)
     1048
     1049
     1050    def test_emptyMiddleGSV(self):
     1051        """
     1052        Tests that a GSV sentence with empty entries in any position
     1053        does not mean the entries in subsequent positions are ignored.
     1054        """
     1055        sentences = [GPGSV_EMPTY_MIDDLE]
     1056        callbacksFired = ['beaconInformationReceived']
     1057
     1058        def checkBeaconInformation():
     1059            beaconInformation = self.adapter._state['beaconInformation']
     1060            self.assertEqual(beaconInformation.seen, 2)
     1061            prns = [satellite.identifier for satellite in beaconInformation]
     1062            self.assertIn(13, prns)
     1063
     1064        self._receiverTest(sentences, callbacksFired, checkBeaconInformation)
     1065
     1066    def test_GGASentences(self):
     1067        """
     1068        Tests that a sequence of GGA sentences fires C{positionReceived},
     1069        C{positionErrorReceived} and C{altitudeReceived}.
     1070        """
     1071        sentences = [GPGGA]
     1072        callbacksFired = ['positionReceived',
     1073                          'positionErrorReceived',
     1074                          'altitudeReceived']
     1075
     1076        self._receiverTest(sentences, callbacksFired)
     1077
     1078
     1079    def test_RMCSentences(self):
     1080        """
     1081        Tests that a sequence of RMC sentences fires C{positionReceived},
     1082        C{speedReceived}, C{headingReceived} and C{timeReceived}.
     1083        """
     1084        sentences = [GPRMC]
     1085        callbacksFired = ['headingReceived',
     1086                          'speedReceived',
     1087                          'positionReceived',
     1088                          'timeReceived']
     1089
     1090        self._receiverTest(sentences, callbacksFired)
     1091
     1092
     1093    def test_GSVSentences(self):
     1094        """
     1095        Verifies that a complete sequence of GSV sentences fires
     1096        C{beaconInformationReceived}.
     1097        """
     1098        sentences = [GPGSV_FIRST, GPGSV_MIDDLE, GPGSV_LAST]
     1099        callbacksFired = ['beaconInformationReceived']
     1100
     1101        def checkPartialInformation():
     1102            self.assertNotIn('_partialBeaconInformation', self.adapter._state)
     1103
     1104        self._receiverTest(sentences, callbacksFired, checkPartialInformation)
     1105
     1106
     1107    def test_emptyMiddleEntriesGSVSequence(self):
     1108        """
     1109        Verifies that a complete sequence of GSV sentences with empty entries
     1110        in the middle still fires C{beaconInformationReceived}.
     1111        """
     1112        sentences = [GPGSV_EMPTY_MIDDLE]
     1113        self._receiverTest(sentences, ["beaconInformationReceived"])
     1114
     1115
     1116    def test_incompleteGSVSequence(self):
     1117        """
     1118        Verifies that an incomplete sequence of GSV sentences does not fire.
     1119        """
     1120        sentences = [GPGSV_FIRST]
     1121        self._receiverTest(sentences)
     1122
     1123
     1124    def test_singleSentenceGSVSequence(self):
     1125        """
     1126        Verifies that the parser does not fail badly when the sequence consists
     1127        of only one sentence (but is otherwise complete).
     1128        """
     1129        sentences = [GPGSV_SINGLE]
     1130        self._receiverTest(sentences, ["beaconInformationReceived"])
     1131
     1132
     1133    def test_GLLSentences(self):
     1134        """
     1135        Verfies that GLL sentences fire C{positionReceived}.
     1136        """
     1137        sentences = [GPGLL_PARTIAL, GPGLL]
     1138        self._receiverTest(sentences,  ['positionReceived'])
     1139
     1140
     1141    def test_HDTSentences(self):
     1142        """
     1143        Verfies that HDT sentences fire C{headingReceived}.
     1144        """
     1145        sentences = [GPHDT]
     1146        self._receiverTest(sentences, ['headingReceived'])
     1147
     1148
     1149    def test_mixedSentences(self):
     1150        """
     1151        Verifies that a mix of sentences fires the correct callbacks.
     1152        """
     1153        sentences = [GPRMC, GPGGA]
     1154        callbacksFired = ['altitudeReceived',
     1155                          'speedReceived',
     1156                          'positionReceived',
     1157                          'positionErrorReceived',
     1158                          'timeReceived',
     1159                          'headingReceived']
     1160
     1161        def checkTime():
     1162            expectedDateTime = datetime.datetime(1994, 3, 23, 12, 35, 19)
     1163            self.assertEquals(self.adapter._state['time'], expectedDateTime)
     1164
     1165        self._receiverTest(sentences, callbacksFired, checkTime)
     1166
     1167
     1168    def test_lotsOfMixedSentences(self):
     1169        """
     1170        Tests for an entire gamut of sentences. These are more than you'd
     1171        expect from your average consumer GPS device. They have most of the
     1172        important information, including beacon information and visibility.
     1173        """
     1174        sentences = [GPGSA] + GPGSV_SEQ + [GPRMC, GPGGA, GPGLL]
     1175
     1176        callbacksFired = ['headingReceived',
     1177                          'beaconInformationReceived',
     1178                          'speedReceived',
     1179                          'positionReceived',
     1180                          'timeReceived',
     1181                          'altitudeReceived',
     1182                          'positionErrorReceived']
     1183
     1184        self._receiverTest(sentences, callbacksFired)
  • new file w/twisted/positioning/test/test_sentence.py

    diff --git c/twisted/positioning/test/test_sentence.py w/twisted/positioning/test/test_sentence.py
    new file mode 100644
    index 0000000..25d0474
    - +  
     1# Copyright (c) 2009-2011 Twisted Matrix Laboratories.
     2# See LICENSE for details.
     3"""
     4Tests for positioning sentences.
     5"""
     6import itertools
     7from zope.interface import classProvides
     8
     9from twisted.positioning import base, ipositioning
     10from twisted.trial.unittest import TestCase
     11
     12
     13sentinelValueOne = "someStringValue"
     14sentinelValueTwo = "someOtherStringValue"
     15
     16
     17
     18class DummyProtocol(object):
     19    """
     20    A simple, fake protocol.
     21    """
     22    classProvides(ipositioning.IPositioningSentenceProducer)
     23
     24    @staticmethod
     25    def getSentenceAttributes():
     26        return ["type", sentinelValueOne, sentinelValueTwo]
     27
     28
     29
     30class DummySentence(base.BaseSentence):
     31    """
     32    A sentence for L{DummyProtocol}.
     33    """
     34    ALLOWED_ATTRIBUTES = DummyProtocol.getSentenceAttributes()
     35
     36
     37
     38class MixinProtocol(base.PositioningSentenceProducerMixin):
     39    """
     40    A simple, fake protocol that declaratively tells you the sentences
     41    it produces using L{base.PositioningSentenceProducerMixin}.
     42    """
     43    SENTENCE_CONTENTS = {
     44        None: [
     45            sentinelValueOne,
     46            sentinelValueTwo,
     47            None # see MixinTests.test_noNoneInSentenceAttributes
     48        ],
     49    }
     50
     51
     52
     53class MixinSentence(base.BaseSentence):
     54    """
     55    A sentence for L{MixinProtocol}.
     56    """
     57    ALLOWED_ATTRIBUTES = MixinProtocol.getSentenceAttributes()
     58
     59
     60
     61class SentenceTestsMixin:
     62    """
     63    Tests for positioning protocols and their respective sentences.
     64    """
     65    def test_attributeAccess(self):
     66        """
     67        Tests that accessing a sentence attribute gets the correct value, and
     68        accessing an unset attribute (which is specified as being a valid
     69        sentence attribute) gets C{None}.
     70        """
     71        thisSentinel = object()
     72        sentence = self.sentenceClass({sentinelValueOne: thisSentinel})
     73        self.assertEquals(getattr(sentence, sentinelValueOne), thisSentinel)
     74        self.assertEquals(getattr(sentence, sentinelValueTwo), None)
     75
     76
     77    def test_raiseOnMissingAttributeAccess(self):
     78        """
     79        Tests that accessing a nonexistant attribute raises C{AttributeError}.
     80        """
     81        sentence = self.sentenceClass({})
     82        self.assertRaises(AttributeError, getattr, sentence, "BOGUS")
     83
     84
     85    def test_raiseOnBadAttributeAccess(self):
     86        """
     87        Tests that accessing bogus attributes raises C{AttributeError}, *even*
     88        when that attribute actually is in the sentence data.
     89        """
     90        sentence = self.sentenceClass({"BOGUS": None})
     91        self.assertRaises(AttributeError, getattr, sentence, "BOGUS")
     92
     93
     94    sentenceType = "tummies"
     95    reprTemplate = "<%s (%s) {%s}>"
     96
     97
     98    def _expectedRepr(self, sentenceType="unknown type", dataRepr=""):
     99        """
     100        Builds the expected repr for a sentence.
     101        """
     102        clsName = self.sentenceClass.__name__
     103        return self.reprTemplate % (clsName, sentenceType, dataRepr)
     104
     105
     106    def test_unknownTypeRepr(self):
     107        """
     108        Test the repr of an empty sentence of unknown type.
     109        """
     110        sentence = self.sentenceClass({})
     111        expectedRepr = self._expectedRepr()
     112        self.assertEqual(repr(sentence), expectedRepr)
     113
     114
     115    def test_knownTypeRepr(self):
     116        """
     117        Test the repr of an empty sentence of known type.
     118        """
     119        sentence = self.sentenceClass({"type": self.sentenceType})
     120        expectedRepr = self._expectedRepr(self.sentenceType)
     121        self.assertEqual(repr(sentence), expectedRepr)
     122
     123
     124
     125class DummyTests(TestCase, SentenceTestsMixin):
     126    """
     127    Tests for protocol classes that implement the appropriate interface
     128    (L{ipositioning.IPositioningSentenceProducer}) manually.
     129    """
     130    def setUp(self):
     131        self.protocol = DummyProtocol()
     132        self.sentenceClass = DummySentence
     133
     134
     135
     136class MixinTests(TestCase, SentenceTestsMixin):
     137    """
     138    Tests for protocols deriving from L{base.PositioningSentenceProducerMixin}
     139    and their sentences.
     140    """
     141    def setUp(self):
     142        self.protocol = MixinProtocol()
     143        self.sentenceClass = MixinSentence
     144
     145
     146    def test_noNoneInSentenceAttributes(self):
     147        """
     148        Tests that C{None} does not appear in the sentence attributes of the
     149        protocol, even though it's in the specification.
     150
     151        This is because C{None} is a placeholder for parts of the sentence you
     152        don't really need or want, but there are some bits later on in the
     153        sentence that you do want. The alternative would be to have to specify
     154        things like "_UNUSED0", "_UNUSED1"... which would end up cluttering
     155        the sentence data and eventually adapter state.
     156        """
     157        sentenceAttributes = self.protocol.getSentenceAttributes()
     158        self.assertNotIn(None, sentenceAttributes)
     159
     160        sentenceContents = self.protocol.SENTENCE_CONTENTS
     161        sentenceSpecAttributes = itertools.chain(*sentenceContents.values())
     162        self.assertIn(None, sentenceSpecAttributes)