Skip to content

Commit ae6146f

Browse files
authored
feat: add Western Electric Rules for control chart pattern detection (#38) (#107)
1 parent 7c559bc commit ae6146f

4 files changed

Lines changed: 1071 additions & 0 deletions

File tree

kstats-core/api/jvm/kstats-core.api

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@ public final class org/oremif/kstats/descriptive/ControlChartKt {
9595
public static final fun ewma (Lkotlin/sequences/Sequence;DDDD)Lorg/oremif/kstats/descriptive/EwmaResult;
9696
public static final fun ewma ([DDDDD)Lorg/oremif/kstats/descriptive/EwmaResult;
9797
public static final fun spcConstants (I)Lorg/oremif/kstats/descriptive/SpcConstants;
98+
public static final fun westernElectricRules (Ljava/lang/Iterable;DD)Lorg/oremif/kstats/descriptive/WesternElectricRulesResult;
99+
public static final fun westernElectricRules (Lkotlin/sequences/Sequence;DD)Lorg/oremif/kstats/descriptive/WesternElectricRulesResult;
100+
public static final fun westernElectricRules ([DDD)Lorg/oremif/kstats/descriptive/WesternElectricRulesResult;
98101
public static final fun xBarRChart (Ljava/util/List;)Lorg/oremif/kstats/descriptive/XBarRChartResult;
99102
public static final fun xBarSChart (Ljava/util/List;)Lorg/oremif/kstats/descriptive/XBarSChartResult;
100103
}
@@ -482,6 +485,23 @@ public final class org/oremif/kstats/descriptive/SummaryStatisticsKt {
482485
public static final fun describe ([D)Lorg/oremif/kstats/descriptive/DescriptiveStatistics;
483486
}
484487

488+
public final class org/oremif/kstats/descriptive/WesternElectricRulesResult {
489+
public fun <init> ([I[I[I[I)V
490+
public final fun component1 ()[I
491+
public final fun component2 ()[I
492+
public final fun component3 ()[I
493+
public final fun component4 ()[I
494+
public final fun copy ([I[I[I[I)Lorg/oremif/kstats/descriptive/WesternElectricRulesResult;
495+
public static synthetic fun copy$default (Lorg/oremif/kstats/descriptive/WesternElectricRulesResult;[I[I[I[IILjava/lang/Object;)Lorg/oremif/kstats/descriptive/WesternElectricRulesResult;
496+
public fun equals (Ljava/lang/Object;)Z
497+
public final fun getRule1 ()[I
498+
public final fun getRule2 ()[I
499+
public final fun getRule3 ()[I
500+
public final fun getRule4 ()[I
501+
public fun hashCode ()I
502+
public fun toString ()Ljava/lang/String;
503+
}
504+
485505
public final class org/oremif/kstats/descriptive/XBarRChartResult {
486506
public fun <init> (DDDLorg/oremif/kstats/descriptive/ControlChartLimits;)V
487507
public final fun component1 ()D

kstats-core/api/kstats-core.klib.api

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,28 @@ final class org.oremif.kstats.descriptive/SpcConstants { // org.oremif.kstats.de
334334
final fun toString(): kotlin/String // org.oremif.kstats.descriptive/SpcConstants.toString|toString(){}[0]
335335
}
336336

337+
final class org.oremif.kstats.descriptive/WesternElectricRulesResult { // org.oremif.kstats.descriptive/WesternElectricRulesResult|null[0]
338+
constructor <init>(kotlin/IntArray, kotlin/IntArray, kotlin/IntArray, kotlin/IntArray) // org.oremif.kstats.descriptive/WesternElectricRulesResult.<init>|<init>(kotlin.IntArray;kotlin.IntArray;kotlin.IntArray;kotlin.IntArray){}[0]
339+
340+
final val rule1 // org.oremif.kstats.descriptive/WesternElectricRulesResult.rule1|{}rule1[0]
341+
final fun <get-rule1>(): kotlin/IntArray // org.oremif.kstats.descriptive/WesternElectricRulesResult.rule1.<get-rule1>|<get-rule1>(){}[0]
342+
final val rule2 // org.oremif.kstats.descriptive/WesternElectricRulesResult.rule2|{}rule2[0]
343+
final fun <get-rule2>(): kotlin/IntArray // org.oremif.kstats.descriptive/WesternElectricRulesResult.rule2.<get-rule2>|<get-rule2>(){}[0]
344+
final val rule3 // org.oremif.kstats.descriptive/WesternElectricRulesResult.rule3|{}rule3[0]
345+
final fun <get-rule3>(): kotlin/IntArray // org.oremif.kstats.descriptive/WesternElectricRulesResult.rule3.<get-rule3>|<get-rule3>(){}[0]
346+
final val rule4 // org.oremif.kstats.descriptive/WesternElectricRulesResult.rule4|{}rule4[0]
347+
final fun <get-rule4>(): kotlin/IntArray // org.oremif.kstats.descriptive/WesternElectricRulesResult.rule4.<get-rule4>|<get-rule4>(){}[0]
348+
349+
final fun component1(): kotlin/IntArray // org.oremif.kstats.descriptive/WesternElectricRulesResult.component1|component1(){}[0]
350+
final fun component2(): kotlin/IntArray // org.oremif.kstats.descriptive/WesternElectricRulesResult.component2|component2(){}[0]
351+
final fun component3(): kotlin/IntArray // org.oremif.kstats.descriptive/WesternElectricRulesResult.component3|component3(){}[0]
352+
final fun component4(): kotlin/IntArray // org.oremif.kstats.descriptive/WesternElectricRulesResult.component4|component4(){}[0]
353+
final fun copy(kotlin/IntArray = ..., kotlin/IntArray = ..., kotlin/IntArray = ..., kotlin/IntArray = ...): org.oremif.kstats.descriptive/WesternElectricRulesResult // org.oremif.kstats.descriptive/WesternElectricRulesResult.copy|copy(kotlin.IntArray;kotlin.IntArray;kotlin.IntArray;kotlin.IntArray){}[0]
354+
final fun equals(kotlin/Any?): kotlin/Boolean // org.oremif.kstats.descriptive/WesternElectricRulesResult.equals|equals(kotlin.Any?){}[0]
355+
final fun hashCode(): kotlin/Int // org.oremif.kstats.descriptive/WesternElectricRulesResult.hashCode|hashCode(){}[0]
356+
final fun toString(): kotlin/String // org.oremif.kstats.descriptive/WesternElectricRulesResult.toString|toString(){}[0]
357+
}
358+
337359
final class org.oremif.kstats.descriptive/XBarRChartResult { // org.oremif.kstats.descriptive/XBarRChartResult|null[0]
338360
constructor <init>(kotlin/Double, kotlin/Double, kotlin/Double, org.oremif.kstats.descriptive/ControlChartLimits) // org.oremif.kstats.descriptive/XBarRChartResult.<init>|<init>(kotlin.Double;kotlin.Double;kotlin.Double;org.oremif.kstats.descriptive.ControlChartLimits){}[0]
339361

@@ -521,5 +543,8 @@ final fun org.oremif.kstats.descriptive/ewma(kotlin.collections/Iterable<kotlin/
521543
final fun org.oremif.kstats.descriptive/ewma(kotlin.sequences/Sequence<kotlin/Double>, kotlin/Double, kotlin/Double, kotlin/Double, kotlin/Double): org.oremif.kstats.descriptive/EwmaResult // org.oremif.kstats.descriptive/ewma|ewma(kotlin.sequences.Sequence<kotlin.Double>;kotlin.Double;kotlin.Double;kotlin.Double;kotlin.Double){}[0]
522544
final fun org.oremif.kstats.descriptive/ewma(kotlin/DoubleArray, kotlin/Double, kotlin/Double, kotlin/Double, kotlin/Double): org.oremif.kstats.descriptive/EwmaResult // org.oremif.kstats.descriptive/ewma|ewma(kotlin.DoubleArray;kotlin.Double;kotlin.Double;kotlin.Double;kotlin.Double){}[0]
523545
final fun org.oremif.kstats.descriptive/spcConstants(kotlin/Int): org.oremif.kstats.descriptive/SpcConstants // org.oremif.kstats.descriptive/spcConstants|spcConstants(kotlin.Int){}[0]
546+
final fun org.oremif.kstats.descriptive/westernElectricRules(kotlin.collections/Iterable<kotlin/Double>, kotlin/Double, kotlin/Double): org.oremif.kstats.descriptive/WesternElectricRulesResult // org.oremif.kstats.descriptive/westernElectricRules|westernElectricRules(kotlin.collections.Iterable<kotlin.Double>;kotlin.Double;kotlin.Double){}[0]
547+
final fun org.oremif.kstats.descriptive/westernElectricRules(kotlin.sequences/Sequence<kotlin/Double>, kotlin/Double, kotlin/Double): org.oremif.kstats.descriptive/WesternElectricRulesResult // org.oremif.kstats.descriptive/westernElectricRules|westernElectricRules(kotlin.sequences.Sequence<kotlin.Double>;kotlin.Double;kotlin.Double){}[0]
548+
final fun org.oremif.kstats.descriptive/westernElectricRules(kotlin/DoubleArray, kotlin/Double, kotlin/Double): org.oremif.kstats.descriptive/WesternElectricRulesResult // org.oremif.kstats.descriptive/westernElectricRules|westernElectricRules(kotlin.DoubleArray;kotlin.Double;kotlin.Double){}[0]
524549
final fun org.oremif.kstats.descriptive/xBarRChart(kotlin.collections/List<kotlin/DoubleArray>): org.oremif.kstats.descriptive/XBarRChartResult // org.oremif.kstats.descriptive/xBarRChart|xBarRChart(kotlin.collections.List<kotlin.DoubleArray>){}[0]
525550
final fun org.oremif.kstats.descriptive/xBarSChart(kotlin.collections/List<kotlin/DoubleArray>): org.oremif.kstats.descriptive/XBarSChartResult // org.oremif.kstats.descriptive/xBarSChart|xBarSChart(kotlin.collections.List<kotlin.DoubleArray>){}[0]

kstats-core/src/commonMain/kotlin/org/oremif/kstats/descriptive/ControlChart.kt

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,263 @@ public fun ewma(
735735
controlLimitWidth: Double,
736736
): EwmaResult = ewma(observations.toList().toDoubleArray(), target, sigma, lambda, controlLimitWidth)
737737

738+
// ── Western Electric Rules ─────────────────────────────────────────────────────
739+
740+
/**
741+
* Indices of observations that violate each of the four Western Electric Rules.
742+
*
743+
* The Western Electric Rules (WER) are a set of heuristics for detecting non-random patterns
744+
* on a control chart beyond the basic 3σ limit check. Each rule flags a different kind of
745+
* abnormality — a single extreme excursion, clusters of moderate excursions, or a prolonged
746+
* shift — so applying them together gives a Shewhart-style chart substantially more power to
747+
* detect small, sustained changes in the process mean.
748+
*
749+
* For each rule, the corresponding array contains the zero-based indices of the observations
750+
* at which the rule fires. The index marks the *trigger point* — the observation whose
751+
* arrival completes the offending pattern. All arrays are sorted in ascending order and an
752+
* observation may appear in more than one array.
753+
*
754+
* @property rule1 indices of single points beyond ±3σ from the center line.
755+
* @property rule2 indices at which 2 of the last 3 consecutive points (including this one)
756+
* lie beyond ±2σ on the same side of the center line.
757+
* @property rule3 indices at which 4 of the last 5 consecutive points (including this one)
758+
* lie beyond ±1σ on the same side of the center line.
759+
* @property rule4 indices at which the last 8 consecutive points (including this one) all
760+
* lie strictly on the same side of the center line, regardless of magnitude.
761+
* @see westernElectricRules
762+
*/
763+
public data class WesternElectricRulesResult(
764+
val rule1: IntArray,
765+
val rule2: IntArray,
766+
val rule3: IntArray,
767+
val rule4: IntArray,
768+
) {
769+
override fun equals(other: Any?): Boolean {
770+
if (this === other) return true
771+
if (other !is WesternElectricRulesResult) return false
772+
return rule1.contentEquals(other.rule1) &&
773+
rule2.contentEquals(other.rule2) &&
774+
rule3.contentEquals(other.rule3) &&
775+
rule4.contentEquals(other.rule4)
776+
}
777+
778+
override fun hashCode(): Int {
779+
var result = rule1.contentHashCode()
780+
result = 31 * result + rule2.contentHashCode()
781+
result = 31 * result + rule3.contentHashCode()
782+
result = 31 * result + rule4.contentHashCode()
783+
return result
784+
}
785+
786+
override fun toString(): String =
787+
"WesternElectricRulesResult(rule1=${rule1.contentToString()}, " +
788+
"rule2=${rule2.contentToString()}, rule3=${rule3.contentToString()}, " +
789+
"rule4=${rule4.contentToString()})"
790+
}
791+
792+
/**
793+
* Applies the Western Electric Rules to the given [observations] to detect non-random
794+
* patterns on a control chart.
795+
*
796+
* The Western Electric Rules (WER), also known as the WECO rules, are a classical set of
797+
* four heuristics that extend a Shewhart chart such as [xBarRChart] or [xBarSChart] beyond
798+
* the basic 3σ limit check. They detect small, sustained shifts and clusters that a 3σ chart
799+
* would miss — a drift of roughly 1σ–2σ is typically caught within a handful of observations.
800+
* WER is often used alongside [cusum] and [ewma] charts, which are also tuned for small
801+
* shifts.
802+
*
803+
* The four rules are:
804+
*
805+
* - **Rule 1** — any single point strictly beyond ±3σ from the center line.
806+
* - **Rule 2** — 2 out of 3 consecutive points strictly beyond ±2σ on the same side.
807+
* - **Rule 3** — 4 out of 5 consecutive points strictly beyond ±1σ on the same side.
808+
* - **Rule 4** — 8 consecutive points strictly on the same side of the center line.
809+
*
810+
* Each rule is evaluated at every index `i` with enough preceding observations to form the
811+
* pattern window (none for rule 1, `i ≥ 2` for rule 2, `i ≥ 4` for rule 3, `i ≥ 7` for rule 4).
812+
* The index stored in the result is the *trigger point* — the observation whose arrival
813+
* completes the pattern. An observation may fire multiple rules at once, so the returned
814+
* arrays can overlap.
815+
*
816+
* All thresholds use strict inequalities, so a point that lands exactly on a sigma boundary
817+
* does not count as a violation. NaN values in the data propagate through the comparisons
818+
* (IEEE 754 semantics) — a NaN observation neither counts as "beyond" a limit nor as being on
819+
* either side of the center, so it silently disqualifies any window that contains it.
820+
*
821+
* ### Example:
822+
* ```kotlin
823+
* val observations = doubleArrayOf(25.0, 24.5, 25.2, 26.1, 25.8, 27.0, 26.5, 28.0)
824+
* val violations = westernElectricRules(observations, center = 25.0, sigma = 1.0)
825+
* violations.rule1 // indices with a point beyond ±3σ
826+
* violations.rule2 // indices at which 2 of 3 points are beyond ±2σ on the same side
827+
* violations.rule3 // indices at which 4 of 5 points are beyond ±1σ on the same side
828+
* violations.rule4 // indices at which 8 consecutive points sit on the same side of the center
829+
* ```
830+
*
831+
* References: Western Electric Company, "Statistical Quality Control Handbook" (1956);
832+
* Montgomery, "Introduction to Statistical Quality Control" (7th ed.), §5.4.
833+
*
834+
* @param observations the sequence of individual measurements to scan. Must contain at
835+
* least 1 element.
836+
* @param center the center line of the control chart, typically the in-control process mean.
837+
* Any finite value is allowed.
838+
* @param sigma the in-control process standard deviation σ. Must be strictly positive.
839+
* @return a [WesternElectricRulesResult] with the trigger indices for each of the four rules.
840+
* @see WesternElectricRulesResult
841+
* @see xBarRChart
842+
* @see xBarSChart
843+
* @see cusum
844+
* @see ewma
845+
*/
846+
public fun westernElectricRules(
847+
observations: DoubleArray,
848+
center: Double,
849+
sigma: Double,
850+
): WesternElectricRulesResult {
851+
if (observations.isEmpty()) throw InsufficientDataException(
852+
"Western Electric Rules require at least 1 observation, got 0"
853+
)
854+
if (sigma <= 0.0) throw InvalidParameterException("sigma must be positive, got $sigma")
855+
856+
// Western Electric Rules (Western Electric Statistical Quality Control Handbook, 1956;
857+
// Montgomery "Introduction to Statistical Quality Control" 7th ed., §5.4):
858+
// Rule 1: any single point beyond ±3σ from the center line.
859+
// Rule 2: 2 out of 3 consecutive points beyond ±2σ on the same side.
860+
// Rule 3: 4 out of 5 consecutive points beyond ±1σ on the same side.
861+
// Rule 4: 8 consecutive points on the same side of the center line.
862+
// All comparisons use strict inequalities, so NaN observations neither count toward a
863+
// violation nor break a run-length pattern via the "above/below" test (IEEE 754).
864+
val n = observations.size
865+
val sigma1Upper = center + sigma
866+
val sigma1Lower = center - sigma
867+
val sigma2Upper = center + 2.0 * sigma
868+
val sigma2Lower = center - 2.0 * sigma
869+
val sigma3Upper = center + 3.0 * sigma
870+
val sigma3Lower = center - 3.0 * sigma
871+
872+
val rule1 = mutableListOf<Int>()
873+
val rule2 = mutableListOf<Int>()
874+
val rule3 = mutableListOf<Int>()
875+
val rule4 = mutableListOf<Int>()
876+
877+
for (i in 0 until n) {
878+
val x = observations[i]
879+
880+
// Rule 1: single point beyond ±3σ.
881+
if (x > sigma3Upper || x < sigma3Lower) {
882+
rule1.add(i)
883+
}
884+
885+
// Rule 2: 2 of last 3 points beyond ±2σ on same side.
886+
if (i >= 2) {
887+
var above2 = 0
888+
var below2 = 0
889+
for (k in i - 2..i) {
890+
val v = observations[k]
891+
if (v > sigma2Upper) above2++
892+
else if (v < sigma2Lower) below2++
893+
}
894+
if (above2 >= 2 || below2 >= 2) {
895+
rule2.add(i)
896+
}
897+
}
898+
899+
// Rule 3: 4 of last 5 points beyond ±1σ on same side.
900+
if (i >= 4) {
901+
var above1 = 0
902+
var below1 = 0
903+
for (k in i - 4..i) {
904+
val v = observations[k]
905+
if (v > sigma1Upper) above1++
906+
else if (v < sigma1Lower) below1++
907+
}
908+
if (above1 >= 4 || below1 >= 4) {
909+
rule3.add(i)
910+
}
911+
}
912+
913+
// Rule 4: 8 consecutive points on the same side of the center line.
914+
if (i >= 7) {
915+
var allAbove = true
916+
var allBelow = true
917+
for (k in i - 7..i) {
918+
val v = observations[k]
919+
if (!(v > center)) allAbove = false
920+
if (!(v < center)) allBelow = false
921+
if (!allAbove && !allBelow) break
922+
}
923+
if (allAbove || allBelow) {
924+
rule4.add(i)
925+
}
926+
}
927+
}
928+
929+
return WesternElectricRulesResult(
930+
rule1 = rule1.toIntArray(),
931+
rule2 = rule2.toIntArray(),
932+
rule3 = rule3.toIntArray(),
933+
rule4 = rule4.toIntArray(),
934+
)
935+
}
936+
937+
/**
938+
* Applies the Western Electric Rules to an [Iterable] of observations to detect non-random
939+
* patterns on a control chart.
940+
*
941+
* See the [DoubleArray] overload of [westernElectricRules] for the full description of the
942+
* four rules, their trigger semantics, and the references.
943+
*
944+
* ### Example:
945+
* ```kotlin
946+
* val observations: List<Double> = listOf(25.0, 24.5, 25.2, 26.1, 25.8, 27.0, 26.5, 28.0)
947+
* val violations = westernElectricRules(observations, center = 25.0, sigma = 1.0)
948+
* violations.rule3 // indices at which 4 of 5 points are beyond ±1σ on the same side
949+
* ```
950+
*
951+
* @param observations the sequence of individual measurements to scan. Must contain at
952+
* least 1 element.
953+
* @param center the center line of the control chart, typically the in-control process mean.
954+
* @param sigma the in-control process standard deviation σ. Must be strictly positive.
955+
* @return a [WesternElectricRulesResult] with the trigger indices for each of the four rules.
956+
* @see westernElectricRules
957+
* @see WesternElectricRulesResult
958+
*/
959+
public fun westernElectricRules(
960+
observations: Iterable<Double>,
961+
center: Double,
962+
sigma: Double,
963+
): WesternElectricRulesResult =
964+
westernElectricRules(observations.toList().toDoubleArray(), center, sigma)
965+
966+
/**
967+
* Applies the Western Electric Rules to a [Sequence] of observations to detect non-random
968+
* patterns on a control chart.
969+
*
970+
* See the [DoubleArray] overload of [westernElectricRules] for the full description of the
971+
* four rules, their trigger semantics, and the references.
972+
*
973+
* ### Example:
974+
* ```kotlin
975+
* val observations: Sequence<Double> = sequenceOf(25.0, 24.5, 25.2, 26.1, 25.8, 27.0, 26.5, 28.0)
976+
* val violations = westernElectricRules(observations, center = 25.0, sigma = 1.0)
977+
* violations.rule3 // indices at which 4 of 5 points are beyond ±1σ on the same side
978+
* ```
979+
*
980+
* @param observations the sequence of individual measurements to scan. Must contain at
981+
* least 1 element.
982+
* @param center the center line of the control chart, typically the in-control process mean.
983+
* @param sigma the in-control process standard deviation σ. Must be strictly positive.
984+
* @return a [WesternElectricRulesResult] with the trigger indices for each of the four rules.
985+
* @see westernElectricRules
986+
* @see WesternElectricRulesResult
987+
*/
988+
public fun westernElectricRules(
989+
observations: Sequence<Double>,
990+
center: Double,
991+
sigma: Double,
992+
): WesternElectricRulesResult =
993+
westernElectricRules(observations.toList().toDoubleArray(), center, sigma)
994+
738995
// ── Validation ─────────────────────────────────────────────────────────────────
739996

740997
private fun validateSubgroups(subgroups: List<DoubleArray>) {

0 commit comments

Comments
 (0)