本文首先介绍了如何从理论上实现一个基本的计步器,然后使用 Ruby 代码对其进行了实现,最后基于 Sinatra 框架实现了一个友好的 Web 界面方便用户输入。通过本文的学习,你也能实现自己的计步器了。
作者
Dessy Daskalov。Dessy 是一个商业工程师,一个充满激情的企业家,一个热爱编程的开发者。她目前是 Nudge Rewards 的 CTO 和联合创始人。当她没有忙于与团队一起构建产品时,可以在 dessydaskalov.com 和 @dess_e 上看到她教其他人编写代码,参加或主持多伦多科技活动的身影。
一个完美的世界
许多反思他们培训的软件工程师都会记得在一个非常完美的世界里生活的乐趣。我们经常被教导解决理想化领域中定义明确的问题。
然后我们被抛到现实世界,面对着它的复杂性和挑战。这很复杂,却让一切变得更加令人兴奋。当你能解决现实生活中的问题时,你就可以构建真正帮助人们的软件。
在本文中,我们将研究一个表面上看起来很简单,但当现实的世界和人混入其中会变得复杂的问题。
我们将一起制作一个基本的计步器。我们将从讨论计步器背后的理论开始,并抛开代码创建一个步长计算解决方案。然后,我们将用代码实现我们的解决方案。最后,我们将在代码中添加一个 Web 层,以便用户使用友好的界面。
让我们撸起袖子,准备解决一个现实世界的问题。
计步器理论
移动设备的兴起带来了一种趋势,即收集越来越多的日常生活数据。许多人收集的一类数据是他们在一段时间内的步数。这些数据可以用于健康跟踪,体育赛事的训练,或者,对于我们这些沉迷于收集和分析数据的人来说,仅仅是为了好玩。步数可以使用计步器来计算,计步器通常使用硬件加速度计的数据作为输入。
什么是加速度计?
加速度计是一种硬件,用于测量、和方向上的加速度。很多人无论走到哪里都会随身携带加速度计,因为目前市场上几乎所有的智能手机都内置了加速度计。、和的方向是相对手机的。
加速度计在三维空间中返回信号。信号是一组随时间记录的数据点。信号的每个分量都是一个时间序列,表示、或方向之一的加速度。时间序列中的每个点都是特定时间点上该方向的加速度。加速度以 g 或 g-force。一个g等于 9.8,这是地球上由于重力引起的平均加速度。
下图显示了三个时间序列的加速度计信号示例。
加速计的采样率(通常可以校准)决定了每秒的测量次数。例如,采样率为100的加速计每秒时间序列为每个、和返回 100 个数据点。
让我们谈谈行走
一个人行走时,每走一步都会有轻微的弹跳。看着一个人从你身边走开时的头顶。他们的头、躯干和臀部在平稳的弹跳运动中保持同步。虽然人们不会弹跳很高,只有一两厘米,但它是一个人行走加速度信号中最清晰、最稳定、最易识别的部分之一。
一个人在走动时每一步都在垂直方向上下弹跳。如果你在地球(或是在太空中漂浮的另一个大质量球)上行走,弹跳的方向和重力方向是一致的。
我们将使用加速度计来计算上下弹跳的步数。因为手机可能在任何方向旋转,我们将利用重力来知道哪个方向是向下的。计步器可以通过计算重力方向上的弹跳次数来计算步数。
让我们看看一个人在衬衫口袋里带着装有加速度计的智能手机走路。
为了简单起见,我们假设此人:
在 方向行走;
在 方向上每一步都弹跳;且
在整个行走过程中保持手机方向一致。
在我们的完美世界中,迈步弹跳产生的加速度将在 方向形成完美的正弦波。正弦波中的每个峰值正好是一步。步数计数变成了计算这些完美峰值的问题。
啊,完美世界的快乐,我们只有在这样的文本中才能体会到。别担心,事情会变得更复杂,更令人兴奋。让我们为我们的世界增添一点现实。
即使是完美的世界也有自然的基本力量
重力引起重力方向的加速度,我们称之为重力加速度。这种加速度是独特的,因为它总是存在的,并且在本文中,它是恒定的,为 9.8 。
假设一部智能手机正面朝上躺在桌上。在这个方向上,我们的坐标系是这样的,负方向就是重力作用的方向。重力会把我们的手机拉向负方向,所以我们的加速度计,即使完全静止,也会在负方向记录到9.8 的加速度。手机在这个方向的加速度计数据显示如下。
注意, 和 在 0 时保持不变,而 在-1g时保持不变。我们的加速度计记录所有加速度,包括重力加速度。
每个时间序列测量该方向上的总加速度。总加速度是用户加速度和重力加速度之和。
用户加速度是指由于用户移动而导致的设备加速度,当手机完全静止时,加速度为 0。然而,当用户随着设备移动时,用户加速度很少是恒定的,因为一个人很难以恒定的加速度移动。
为了计算步数,我们对用户在重力方向上产生的弹跳感兴趣。这意味着我们要从三维加速度信号中分离出描述用户重力方向加速度的一维时间序列。
在我们的简单例子中,重力加速度在 和 中为 0,在 )中为 9.8 。因此,在我们的总加速度图中,和 在 0 左右波动,而 在-1g左右波动。在我们的用户加速度图中,我们注意到由于我们去掉了重力加速度,所有三个时间序列都在 0 左右波动。注意中明显的峰值。那是因为台阶弹跳。在我们的最后一个图中,重力加速度 在-1g处是常数, 和 在 0 处是常数。
所以,在我们的例子中,我们感兴趣的重力时间序列方向上的一维用户加速度是 。尽管 不如我们的完美正弦波平滑,但我们可以识别峰值,并使用这些峰值来计算步数。到目前为止,还不错。现在,让我们为我们的世界添加更多的现实。
人是复杂的生物
如果一个人把手机放在肩上的袋子里,手机的位置不稳会怎么样呢?更糟糕的是,如果手机在走的过程中在包中旋转了一部分,如下图,会怎么样?
啊。现在我们所有三个方向都有一个非零的重力加速度,所以重力方向上的用户加速度现在被分成三个时间序列。为了确定用户在重力方向上的加速度,我们首先要确定重力作用的方向。为此,我们必须将三个时间序列中的每个时间序列中的总加速度分解为用户加速度时间序列和重力加速度时间序列。
然后,我们可以分离出用户加速度在重力方向上的每个分量,从而得到重力方向上的用户加速度时间序列。
我们将其定义为以下两个步骤:
将总加速度分为用户加速度和重力加速度。
在重力方向隔离用户加速度。
我们将以数学家的身份分别看每一步。
1.将总加速度分解为用户加速度和重力加速度
我们可以使用一个称为过滤器的工具将总加速度时间序列拆分为用户加速度时间序列和重力加速度时间序列。
低通和高通滤波器
滤波器是信号处理中用来去除信号中不需要的成分的工具。
低通滤波器允许低频信号通过,同时衰减高于设定阈值的信号。相反,高通滤波器允许高频信号通过,同时衰减低于设定阈值的信号。以音乐为例,低通滤波器可以消除高音,高通滤波器可以消除低音。
在我们的场景中,频率,以 Hz 为单位,表示加速度变化有多快。恒定加速度的频率为0 Hz,而非恒定加速度的频率为非零。这意味着我们恒定的重力加速度是一个0 Hz 的信号,而用户加速度不是。
对于每个分量,我们可以通过一个低通滤波器传递总加速度,只剩下重力加速度时间序列。然后我们可以从总加速度中减去重力加速度,得到用户加速度时间序列。
滤波器种类繁多。我们将使用的是无限脉冲响应(IIR)滤波器。我们选择IIR滤波器是因为它的低开销和易于实现。我们选择的IIR滤波器基于以下公式实现:
数字滤波器的设计超出了本文的范围,但有必要进行简短的讨论。这是一个值得研究的话题,有许多实际应用。一个数字滤波器可以被设计来消除任何频率或频率范围。公式中 和 的值是系数,根据截止频率和我们要保留的频率范围设置。
我们想取消除恒定重力加速度以外的所有频率,所以我们选择了衰减频率大于 0.2Hz 的系数。请注意,我们将阈值设置为略高于0 Hz。虽然重力确实产生了一个真正的 0Hz 加速度,但我们真实的、不完美的世界却有真实的、不完美的加速度计,所以我们在测量中考虑了一点误差。
实现低通滤波器
让我们使用前面的示例来完成一个低通滤波器实现。我们分开:
- 为 和 ,
- 为 和 ,
- 为 和 。
我们将重力加速度的前两个值初始化为0,这样公式就有了初始值。
然后我们将实现每个时间序列的过滤公式。
低通滤波后得到的时间序列在下图中。
和 )在 0 附近,很快下降到 。 中的初始 0 值来自公式的初始化。
现在,为了计算用户加速度,我们可以从总加速度中减去重力加速度:
结果是下图中显示的时间序列。我们已经成功地将总加速度分为用户加速度和重力加速度!
2.在重力方向上隔离用户加速度
、 和 )包括用户的所有运动,而不仅仅是重力方向的运动。我们的目标是最终得到一个一维时间序列,表示用户在重力方向的加速度。这将包括每个方向上的用户加速部分。
我们开始吧。首先是一些线性代数。现在不要把数学家的身份忘掉。
点积
在使用坐标时,你很快就会了解点积,它是比较、和坐标的大小和方向的基本工具之一。
点积将我们从三维空间带到一维空间。当我们取两个时间序列三维空间中的用户加速度和重力加速度的点积,我们会在一维空间中得到一个时间序列,代表用户加速度在重力方向上的部分。我们可以称这个新的时间序列为,因为,每个重要的时间序列都应该有一个名字。
计算点积
我们可以使用公式 实现前面示例中的点积,在一维空间中计算出。
现在我们可以直观地找出 中步子在哪里。点积强大且简单。
现实世界中的解决方案
我们发现当我们面对现实世界的挑战时,我们看似简单的问题是变得更加复杂。然而,我们离计算出步数越来越近了,我们可以看到 是如何开始出现类似于我们理想的正弦波的。但是,只是“有一点”开始。我们仍然需要使混乱的 时间序列更加平滑。 在当前状态下有四个主要问题。让我们逐一检查一下。
1.跳动的波峰
是“跳动”的,因为手机可以随着每一步抖动,为我们的时间序列增加一个高频分量。这种跳跃被称为噪音。通过研究大量的数据集,我们确定一个步伐加速度最大为 5 Hz。我们可以用一个低通IIR滤波器来去除噪声,拾取 以及 衰减 5Hz 以上的所有信号。
2.缓慢的波峰
在采样率为 100 的情况下,以 显示的慢峰值跨度为1.5秒,这太慢了,不可能是一个步长。在研究足够的数据样本时,我们已经确定了我们能采取的最慢的步伐是在1Hz的频率下。较慢的加速是由于低频部分造成的,我们可以再次使用高通IIR滤波器消除,设置以及取消所有低于1Hz的信号。
3.短小的波峰
当一个人在使用应用程序或打电话时,加速计会记录重力方向上的微小运动,在我们的时间序列中呈现为短峰值。我们可以通过设置一个最小阈值来消除这些短峰值,并且每当 在正方向越过该阈值时计数一个步长。
4.颠簸的山峰
我们的计步器应该能计算许多不同走路的人,所以我们根据大量的人和走路的样本设置了最小和最大步长频率。这意味着我们有时可能会过滤的过多或过少。虽然我们通常会有相当平滑的山峰,但偶尔也会有一个“更颠簸”的山峰。
当颠簸发生在我们的阈值,我们可能会错误地为一个峰值计算太多的步数。我们将使用一种称为滞后的方法来解决这个问题。滞后是指输出对过去输入的依赖性。我们可以在正方向上计算阈值交叉,也可以在负方向上计算 0 交叉。然后,我们只计算阈值交叉发生在 0 交叉之后的步伐,确保每个步伐只计算一次。
正确的波峰
去除这四种情况,我们已经设法使混乱的 非常接近理想的正弦波,允许我们计算步数。
简要回顾
乍一看,问题很简单。然而,现实世界却给我们设置了一些阻碍。让我们回顾一下我们是如何解决问题的:
我们从总加速度 开始。
我们使用低通滤波器将总加速度分为用户加速度 和重力加速度。
我们取 和 的点积来获得重力方向上的用户加速度 。
我们再次使用低通滤波器去除 的高频分量,去除噪声。
我们使用高通滤波器来抵消 的低频分量,去除慢波峰。
我们设置了一个阈值来忽略短波峰。
我们使用滞后来避免重复计算峰值起伏的步数。
作为培训或学术环境中的软件开发人员,我们可能会被要求编写代码来计算信号中的步数。虽然这可能是一个有趣的编码挑战,但我们不可能将其应用于实际情况。我们看到,在现实中,由于重力和人们的参与,问题变得更加复杂。我们使用数学工具来解决复杂的问题,并且能够解决现实世界中的问题。是时候把我们的解决方案转换成代码了。
深入代码
本章的目标是在 Ruby 中创建一个 Web 应用程序,它接受加速计数据,解析、处理和分析数据,并返回步数、行驶的距离和经过的时间。
前期工作
我们的解决方案需要我们对时间序列进行多次过滤。与其在我们的程序中添加过滤代码,不如创建一个负责过滤的类,这样如果我们需要优化或修改它,我们只需要更改这个类。这种策略称为关注点分离(separation of concerns),这是一种常用的设计原则,它促进将程序分成不同的部分,其中每个部分都有一个主要关注点。这是一种编写干净易于扩展的可维护代码的好方法。在本章中,我们将多次重温这个观点。
让我们深入研究 Filter
类中的过滤代码。
class Filter
COEFFICIENTS_LOW_0_HZ = {
alpha: [1, -1.979133761292768, 0.979521463540373],
beta: [0.000086384997973502, 0.000172769995947004, 0.000086384997973502]
}
COEFFICIENTS_LOW_5_HZ = {
alpha: [1, -1.80898117793047, 0.827224480562408],
beta: [0.095465967120306, -0.172688631608676, 0.095465967120306]
}
COEFFICIENTS_HIGH_1_HZ = {
alpha: [1, -1.905384612118461, 0.910092542787947],
beta: [0.953986986993339, -1.907503180919730, 0.953986986993339]
}
def self.low_0_hz(data)
filter(data, COEFFICIENTS_LOW_0_HZ)
end
def self.low_5_hz(data)
filter(data, COEFFICIENTS_LOW_5_HZ)
end
def self.high_1_hz(data)
filter(data, COEFFICIENTS_HIGH_1_HZ)
end
private
def self.filter(data, coefficients)
filtered_data = [0,0]
(2..data.length-1).each do |i|
filtered_data << coefficients[:alpha][0] *
(data[i] * coefficients[:beta][0] +
data[i-1] * coefficients[:beta][1] +
data[i-2] * coefficients[:beta][2] -
filtered_data[i-1] * coefficients[:alpha][1] -
filtered_data[i-2] * coefficients[:alpha][2])
end
filtered_data
end
end
每当我们的程序需要过滤一个时间序列时,我们可以调用过滤器中的一个类方法,其中包含我们需要过滤的数据:
low_0_hz
用于接近 0 hz的低通滤波器信号low_5_hz
用于 5 hz或以下的低通滤波器信号high_1_hz
用于 1 hz以上的高通滤波器信号
每个类方法都调用 filter
,它实现IIR过滤器并返回结果。如果我们希望在将来添加更多的过滤器,我们只需要更改这个类。注意,所有的常数都是在顶部定义的。这使我们的类更容易阅读和理解。
输入格式
我们的输入数据来自Android手机和iPhone等移动设备。现在市场上的大多数手机都内置了加速度计,能够记录总加速度。让我们将记录总加速度的输入数据格式称为组合格式。许多(但不是所有)设备还可以分别记录用户加速度和重力加速度。我们把这种格式称为分离格式。能够以分离格式返回数据的设备必然能够以组合格式返回数据。然而,事实并非总是如此。有些设备只能以组合格式记录数据。组合格式的输入数据需要通过低通滤波器将其转换为分离格式。
我们希望我们的程序能够处理市场上所有带有加速计的移动设备,因此我们需要接受两种格式的数据。让我们看看我们将分别接受的两种格式。
组合格式
组合格式中的数据是、和 方向上随时间变化的总加速度。、和 值将用逗号分隔,单位时间的样本将用分号分隔。
分离格式
分离格式返回在、和 方向随时间推移的用户加速度和重力加速度。用户加速度值将通过管道与重力加速度值分隔。
有多种输入格式但没有一个标准
处理多种输入格式是一个常见的编程问题。如果我们想让整个程序同时处理这两种格式,那么处理数据的每一段代码都需要知道如何处理这两种格式。这很快会变得非常混乱,特别是如果添加了第三种(或第四种、第五种或第一百种)输入格式。
标准格式
对于我们来说,处理这一问题的最干净的方法是采用两种输入格式,并尽快将它们调整为标准格式,从而允许程序的其余部分使用这种新的标准格式。我们的解决方案要求我们分别处理用户加速度和重力加速度,因此我们的标准格式需要被分成两种加速度。
我们的标准格式允许我们存储一个时间序列,其中每个元素代表一个时间点的加速度。我们把它定义为数组的数组的数组。我们详细看一下吧。
第一个数组只是保存所有数据的包装器。
第二组数组为每个数据样本包含一个数组。如果我们的采样率是100,并且我们对数据进行10秒的采样,我们将得到 或者 1000 个。
第三组数组是包含在第二组数组中的一对数组。它们都包含、和 方向的加速度数据;第一个代表用户加速度,第二个代表重力加速度。
管道
我们系统的输入将是来自加速度计的数据、关于用户行走的信息(性别、步长等)和关于试行走本身的信息(采样率、实际采样的步数等)。我们的系统将应用信号处理解决方案,并输出计算的步数、实际步数与计算步数之间的差值、行走的距离和经过的时间。从输入到输出的整个过程可以看作一个管道。
本着关注点分离的精神,我们将分别为管道解析、处理和分析的每个不同组件编写代码。
解析
考虑到我们希望数据尽可能早地采用标准格式,编写一个解析器是有意义的,它允许我们采用两种已知的输入格式,并将它们转换为标准输出格式,作为管道的第一个组件。我们的标准格式将用户加速度和重力加速度分开,这意味着如果我们的数据是组合格式,我们的解析器需要首先将其通过低通过滤器转换为标准格式。
在将来,如果我们需要添加另一种输入格式,我们唯一需要接触的代码就是这个解析器。让我们再次分离关注点,并创建一个解析器类来处理解析过程。
class Parser
attr_reader :parsed_data
def self.run(data)
parser = Parser.new(data)
parser.parse
parser
end
def initialize(data)
@data = data
end
def parse
@parsed_data = @data.to_s.split(';').map { |x| x.split('|') }
.map { |x| x.map { |x| x.split(',').map(&:to_f) } }
unless @parsed_data.map { |x| x.map(&:length).uniq }.uniq == [[3]]
raise 'Bad Input. Ensure data is properly formatted.'
end
if @parsed_data.first.count == 1
filtered_accl = @parsed_data.map(&:flatten).transpose.map do |total_accl|
grav = Filter.low_0_hz(total_accl)
user = total_accl.zip(grav).map { |a, b| a - b }
[user, grav]
end
@parsed_data = @parsed_data.length.times.map do |i|
user = filtered_accl.map(&:first).map { |elem| elem[i] }
grav = filtered_accl.map(&:last).map { |elem| elem[i] }
[user, grav]
end
end
end
end
Parser
有一个类级 run
方法和一个初始化器。这是一个我们将多次使用的模式,因此值得讨论。初始化器通常应该用于设置对象,不应该做很多工作。Parser
的初始化器只是以组合或分离的格式获取数据,并将其存储在实例变量 @data
中。parse
实例方法在内部使用 @data
,并执行繁重的解析工作,并将标准格式的结果设置为@parsed_data
。在我们的例子中,我们永远不需要在不立即调用 parse
的情况下实例化Parser
实例。因此,我们添加了一个方便的类级 run
方法,该方法实例化Parser
的实例,对其调用 parse
,并返回对象的实例。我们现在可以将输入数据传递给 run
,因为我们将收到一个 Parser
实例,其中 @parsed_data
已经设置。
让我们看看我们的parse
方法。该过程的第一步是获取字符串数据并将其转换为数值型数据,从而生成一个数组的数组的数组。听起来熟悉吗?接下来我们要做的就是确保格式符合预期。如果有最里面的数组不是正好三个元素,那么我们抛出一个异常。如果没有问题我们就继续。
请注意在这个阶段,两种格式之间的 @parsed_data
存在差异。在组合格式中,它只包含一个数组:
在分离格式中,它包含正好由两个数组组成的数组:
在此操作之后,分离格式就会成为我们所需的标准格式。但是,如果数据是组合的(或者只有一个数组),那么我们会进行两个循环。第一个循环使用 :low_0_hz
类型的 Filter
将总加速度拆分为重力加速度和用户加速度,第二个循环将数据重新组织为标准格式。
parse
给我们留下了以标准格式保存的 @parsed_data
数据,不管我们是从组合数据还是分离数据开始。
随着我们的程序变得越来越复杂,需要改进的一个方面是通过抛出带有更具体错误消息的异常,让用户能够更快地跟踪常见的输入格式问题,从而使用户更轻松。
处理
根据我们定义的解决方案,在计算步骤之前,我们需要我们的代码对解析的数据执行一些操作:
使用点积分离重力方向的运动。
用低通滤波器和高通滤波器去除跳跃(高频)和慢(低频)峰值。
我们将在步伐计数期间处理短峰和颠簸峰。
既然我们已经有了标准格式的数据,我们就可以对它进行处理,使我们可以分析它来计算步数。
处理的目的是以标准格式获取数据,并对其进行增量清理,使其达到尽可能接近理想正弦波的状态。我们的两个处理操作,点积和过滤,是完全不同的,但都是为了处理我们的数据,所以我们将创建一个类称为 Processor
。
class Processor
attr_reader :dot_product_data, :filtered_data
def self.run(data)
processor = Processor.new(data)
processor.dot_product
processor.filter
processor
end
def initialize(data)
@data = data
end
def dot_product
@dot_product_data = @data.map do |x|
x[0][0] * x[1][0] + x[0][1] * x[1][1] + x[0][2] * x[1][2]
end
end
def filter
@filtered_data = Filter.low_5_hz(@dot_product_data)
@filtered_data = Filter.high_1_hz(@filtered_data)
end
end
我们再次看到 run
和 initialize
方法模式。run
直接调用我们的两个处理器方法 dot_product
和 filter
。每个方法完成两个处理操作中的一个。dot_product
隔离重力方向的运动,filter
按顺序应用低通和高通滤波器以消除跳跃和缓慢的峰值。
计步器功能
如果有关于使用计步器的人的信息,我们可以测量的不仅仅是步数。我们的计步器将测量移动距离和经过时间,以及运动步伐。
移动距离
移动计步器一般由一个人使用。步行过程中移动的距离是用人的步长乘以所走的步数来计算的。如果步长未知,我们可以使用可选的用户信息,如性别和身高来近似它。让我们创建一个用户类来封装这些相关信息。
class User
GENDER = ['male', 'female']
MULTIPLIERS = {'female' => 0.413, 'male' => 0.415}
AVERAGES = {'female' => 70.0, 'male' => 78.0}
attr_reader :gender, :height, :stride
def initialize(gender = nil, height = nil, stride = nil)
@gender = gender.to_s.downcase unless gender.to_s.empty?
@height = Float(height) unless height.to_s.empty?
@stride = Float(stride) unless stride.to_s.empty?
raise 'Invalid gender' if @gender && !GENDER.include?(@gender)
raise 'Invalid height' if @height && (@height <= 0)
raise 'Invalid stride' if @stride && (@stride <= 0)
@stride ||= calculate_stride
end
private
def calculate_stride
if gender && height
MULTIPLIERS[@gender] * height
elsif height
height * (MULTIPLIERS.values.reduce(:+) / MULTIPLIERS.size)
elsif gender
AVERAGES[gender]
else
AVERAGES.values.reduce(:+) / AVERAGES.size
end
end
end
在我们的类的顶部,我们定义常量以避免在整个过程中硬编码常数和字符串。为了便于讨论,我们假设 MULTIPLIERS
和 AVERAGES
的值是从不同人群的大样本中确定的。
我们的初始化器接受性别、身高和步长作为可选参数。如果传入了可选参数,我们的初始化器会在一些数据格式化之后设置相同名称的实例变量。如果存在无效值则会引发异常。
即使提供了所有可选参数,输入步长也有更高的优先级。如果没有提供,则 calculate_stride
方法将为用户确定最精确的步长。这是通过 if
语句完成的:
计算步长最准确的方法是使用一个人的身高和基于性别的乘数,前提是我们有一个有效的性别和身高。
一个人的身高比他们的性别更能预测步长。如果我们有身高但没有性别,我们可以用
MULTIPLIERS
中两个值的平均值乘以身高。如果我们只有一个性别,我们可以使用
AVERAGES
种步长的平均值。最后,如果我们什么都没有,我们可以取 ``AVERAGES` 中两个值的平均值作为我们的步长。
注意,if
语句越低,步长就越不准确。在任何情况下,我们的用户类都尽可能地确定步长。
经过时间
花费在行走中的时间是用 Processor
的 @parsed_data
中的数据样本数除以设备的采样率(如果有的话)来衡量的。由于速率更多地是与测试行走本身有关,而不是与用户有关,而且 User
类实际上不必知道采样率,因此现在是创建一个非常小的 Trial
类的好时机。
class Trial
attr_reader :name, :rate, :steps
def initialize(name, rate = nil, steps = nil)
@name = name.to_s.delete(' ')
@rate = Integer(rate.to_s) unless rate.to_s.empty?
@steps = Integer(steps.to_s) unless steps.to_s.empty?
raise 'Invalid name' if @name.empty?
raise 'Invalid rate' if @rate && (@rate <= 0)
raise 'Invalid steps' if @steps && (@steps < 0)
end
end
Trial
中的所有属性读取器都是基于传入的参数在初始化器中设置的:
name
是特定试验的名称,有助于区分不同的试验。rate
是试验期间加速计的采样率。steps
用于设置实际行走的步数,这样我们就可以记录用户走的实际步数和程序计算的步数之间的差异。
很像我们的 User
类,有些信息是可选的。如果有的话,我们有机会输入试验的细节。如果我们没有这些细节,我们的程序就会绕过计算额外的结果,比如花在行走上的时间。与我们的 User
类的另一个相似之处是防止无效值。
行走步数
是时候在代码中实现我们的步伐计数策略了。到目前为止,我们有一个 Processor
类,它包含 @filtered_data
,这是我们的纯粹时间序列,表示用户在重力方向的加速度。我们还有一些类,它们为我们提供有关用户和试验的必要信息。我们缺少的是一种用用户和试验的信息分析 @filtered_data
的方法,可以计算步数、测量距离和测量时间。
我们程序的分析部分不同于 Processor
的数据操作,也不同于 User
和 Trial
类的信息收集和聚合。让我们创建一个名为 Analyzer
的新类来执行此数据分析。
class Analyzer
THRESHOLD = 0.09
attr_reader :steps, :delta, :distance, :time
def self.run(data, user, trial)
analyzer = Analyzer.new(data, user, trial)
analyzer.measure_steps
analyzer.measure_delta
analyzer.measure_distance
analyzer.measure_time
analyzer
end
def initialize(data, user, trial)
@data = data
@user = user
@trial = trial
end
def measure_steps
@steps = 0
count_steps = true
@data.each_with_index do |data, i|
if (data >= THRESHOLD) && (@data[i-1] < THRESHOLD)
next unless count_steps
@steps += 1
count_steps = false
end
count_steps = true if (data < 0) && (@data[i-1] >= 0)
end
end
def measure_delta
@delta = @steps - @trial.steps if @trial.steps
end
def measure_distance
@distance = @user.stride * @steps
end
def measure_time
@time = @data.count/@trial.rate if @trial.rate
end
end
在 Analyzer
中,我们要做的第一件事是定义一个阈值常量,我们将使用它来避免将短峰值作为步长来计算。为了讨论这个问题,我们假设我们已经分析了许多不同的数据集,并确定了一个容纳这些数据集中数量最多的阈值。阈值最终会变成动态的,并随着不同的用户而变化,这取决于他们所采取的计算步长和实际步长;如果你愿意的话这将是一个学习算法。
我们的 Analyzer
的初始化器接受一个 data
参数以及 User
和 Trial
实例,并将实例变量 @data
、@User
和 @trial
设置为传入参数。run
方法调用measure_steps
、measure_delta
、measure_distance
和 measure_time
。让我们来看看每种方法。
measure_steps
终于到了我们的步数计算应用程序部分。我们在测量步数中要做的第一件事是初始化两个变量:
@steps
用于计算步数。count_steps
用于滞后,以确定是否允许在某个时间点对步数进行计数。
然后我们迭代 @processor.filtered_data
,如果当前值大于或等于 THRESHOLD
,且上一个值小于 THRESHOLD
,则我们已经在正方向越过阈值,这意味着前进了一步。如果 count_steps
为 false
,则 unless
语句会跳转到下一个数据点,这表明我们已经计算了该峰值的步数。如果没有,我们将 @steps
增加1,并将 count_steps
设置为 false
,以防止为该峰值计算更多步数。下一个 if
语句将 count_steps
设置为true
,一旦时间序列在负方向上穿过x轴,我们就进入下一个峰值。
好了,到了我们程序的计算步数部分!我们的 Processor
类做了大量的工作来清理时间序列并删除可能导致计算错误步数的频率,因此我们实际的步数计算实现并不复杂。
值得一提的是,我们将整个时间序列存储在内存中。我们的试验都是短距离步行,所以这目前不是问题,但最终我们希望用大量数据分析长距离步行。理想情况下,我们希望将数据流化,只在内存中存储时间序列的很小部分。记住这一点,我们已经投入工作,以确保我们只需要当前数据点和它之前的数据点。另外,我们已经使用布尔值实现了滞后,所以我们不需要在时间序列中向后观察来确保我们在0处穿过x轴。
在考虑产品未来可能的迭代和为每个可想象的产品方向过度设计解决方案之间有一个很好的平衡。在这种情况下,我们可以合理地假设,在不久的将来,我们将不得不处理更长的步行时间,而在步数计算中考虑这一点的成本是相当低的。
measure_delta
如果试验提供了行走过程中所行进的实际步数,则 measure_delta
将返回计算步数和实际步数之间的差值。
measure_distance
距离是通过用户的步幅乘以步数来测量的。由于距离取决于步数,因此必须在 measure_distance
之前调用measure_steps
。
measure_time
只要我们有一个采样率,时间就是用过滤后的数据中的样本总数除以采样率来计算的。时间是以秒为单位计算的。
用管道把它们连在一起
我们的Parser
、Processor
和 Analyzer
类虽然可以单独使用,但结合起来肯定更好。我们的程序会经常使用它们来运行我们前面介绍的管道。由于管道需要经常运行,我们将创建一个 Pipeline
类来运行它。
class Pipeline
attr_reader :data, :user, :trial, :parser, :processor, :analyzer
def self.run(data, user, trial)
pipeline = Pipeline.new(data, user, trial)
pipeline.feed
pipeline
end
def initialize(data, user, trial)
@data = data
@user = user
@trial = trial
end
def feed
@parser = Parser.run(@data)
@processor = Processor.run(@parser.parsed_data)
@analyzer = Analyzer.run(@processor.filtered_data, @user, @trial)
end
end
我们使用我们现在熟悉的 run
模式和提供加速计数据的Pipeline
,以及 User
和 Trial
的实例。feed
方法实现了管道,它需要使用加速计数据运行 Parser
,然后使用解析器的解析数据运行 Processor
,最后使用处理器的过滤数据运行 Analyzer
。管道保存 @parser
、@processor
和 @analyzer
实例变量,这样程序就可以访问这些对象的信息,以便通过应用程序进行显示。
添加友好接口
我们已经完成了我们计划中最复杂的部分。接下来,我们将构建一个 Web 应用程序,以用户满意的格式呈现数据。Web应用程序自然地将数据处理与数据表示分离开来。在编写代码之前,让我们先从用户的角度来看一下我们的应用程序。
用户场景
当用户通过导航到 /uploads
第一次进入应用程序时,他们会看到一个现有数据表,以及一个通过上传加速计输出文件、试验和用户信息提交新数据的表单。
提交表单将数据存储到文件系统,对其进行解析、处理和分析,并重定向回 /uploads
并在表中显示为新纪录。
单击记录的 Detail 链接会向用户显示图中的以下视图。
提供的信息包括用户通过上传表单输入的值、我们的程序计算的值、点积运算后的时间序列图以及过滤后的时间序列图。用户可以使用 Back to Uploads 链接导航回/uploads
。
让我们看看上面概述的功能在技术上对我们意味着什么。我们需要两个我们还没有的主要组件:
- 一种存储和检索用户输入数据的方法。
- 具有基本界面的 Web 应用程序。
让我们检查一下这两个需求。
1.存储和检索数据
我们的应用程序需要将输入数据存储到文件系统,并从中检索数据。我们将创建一个 Upload
类来执行此操作。由于该类只处理文件系统,与计步器的实现没有直接关系,为了简洁起见,我们省略了它,但是值得讨论它的基本功能。我们的Upload类有三个类级方法用于文件系统访问和检索,所有这些方法都返回一个或多个 Upload
实例:
create
使用一个包含用户和试验信息的文件。它将文件存储到文件系统中,其中文件名包含用户和试验信息。@file_path
、@user
和@trial
实例变量分别允许访问文件路径、用户对象和试验对象。find
输入文件路径并返回Upload
的实例。all
返回一个Upload
实例数组,文件系统中的每个数据文件对应一个实例。
上传中的关注点分离
再一次,我们明智地在我们的项目中分离关注点。所有与存储和检索相关的代码都包含在 Upload
类中。随着应用程序的增长,我们可能希望使用数据库,而不是将所有内容保存到文件系统中。到时候,我们所要做的就是改变 Upload
类。这使我们的重构变得简单和干净。
将来,我们可以将 User
和 Trial
对象保存到数据库中。Upload
中的 create
、find
和 all
方法也将与 User
和 Trial
密切相关。这意味着我们可能会将这些类重构到它们自己的类中,以处理一般的数据存储和检索,而我们的每个 User
、Trial
和 Upload
类都将从该类继承。我们最终可能会向该类添加辅助查询方法,并从中继续构建它。
2.构建Web应用程序
Web 应用很常见,我们将利用开源社区的重要工作,并使用现有的框架为我们完成这部分枯燥的工作。Sinatra 框架就是符合我们要求的框架。用这个工具自己的话来说,Sinatra是“一个用 Ruby 快速创建 Web 应用程序的DSL”。完美~~
我们的 Web 应用程序需要响应 HTTP 请求,因此我们需要一个文件来为 HTTP 方法和 URL 的每个组合定义一个路由和相关的代码块。我们叫它 pedometer.rb
。
get '/uploads' do
@error = "A #{params[:error]} error has occurred." if params[:error]
@pipelines = Upload.all.inject([]) do |a, upload|
a << Pipeline.run(File.read(upload.file_path), upload.user, upload.trial)
a
end
erb :uploads
end
get '/upload/*' do |file_path|
upload = Upload.find(file_path)
@pipeline = Pipeline.run(File.read(file_path), upload.user, upload.trial)
erb :upload
end
post '/create' do
begin
Upload.create(params[:data][:tempfile], params[:user], params[:trial])
redirect '/uploads'
rescue Exception => e
redirect '/uploads?error=creation'
end
end
pedometer.rb
允许我们的应用程序对每个路由的 HTTP 请求做出响应。每个路由的代码块通过 Upload
从文件系统检索数据或将数据存储到文件系统,然后渲染视图或重定向。实例化的实例变量将直接用于我们的视图中。这些视图只是显示数据,而不是我们应用程序的焦点,因此我们将把它们的代码留在本章之外。
让我们分别查看 pedometer.rb
中的每条路由。
GET /uploads
导航到 http://localhost:4567/uploads 向我们的应用程序发送 HTTP GET请求,触发 get'/uploads'
代码。代码运行文件系统中的所有上传运行管道,并渲染 uploads
视图,该视图显示上传列表和提交新上传的表单。如果包含错误参数,将创建一个错误字符串,uploads
视图将显示错误。
GET /upload/*
单击每个上传的 Detail 链接会发送一个HTTP GET 给 /upload
,其中包含上传的文件路径。管道运行,并渲染 /upload
视图。该视图显示上传的详细信息,包括使用名为 HighCharts 的 JavaScript 库创建的图表。
POST /create
当用户在 uploads
视图中提交表单时,将调用我们的最后一个路由,即到达 create
的HTTP POST。代码块创建一个新的 Upload
,使用 params
散列获取用户通过表单输入的值,并重定向回 /uploads
。如果在创建过程中发生错误,重定向到 /uploads
将包含一个错误参数,以让用户知道发生了错误。
功能齐全的应用程序
瞧!我们已经建立了一个功能齐全的应用程序,具有真正的适用性。
现实世界给我们带来了错综复杂的挑战。软件能够以最小的资源规模解决这些挑战。作为软件工程师,我们有能力在我们的家庭、社区和世界中创造积极的变化。我们的培训,无论是学术上的还是其他方面的,都可能使我们具备解决问题的技能,能够编写代码来解决孤立的、定义良好的问题。当我们成长和磨练自己的技能时,我们就应该扩展这种训练来解决实际问题。我希望本章能让ni体验到将实际问题分解为可寻址的小部分,并编写漂亮、干净、可扩展的代码来构建解决方案。
为在一个无限刺激的世界里解决有趣的问题干杯。