Detecting Suspicious Damage Patterns in Shipping Containers Using Spatial Analysis

elixir spatial-analysis pattern-detection fraud-detection geometry

Detecting Suspicious Damage Patterns in Shipping Containers Using Spatial Analysis

The Problem

Shipping containers get damaged during transport. That’s expected. What’s not expected? Impossible damage patterns that suggest fraud or reporting errors.

When a container arrives at port with damage, someone needs to file a claim. But here’s the thing: not all damage patterns make physical sense. If a container has damage on the top-front-left corner AND the bottom-back-right corner, but nothing in between—that’s weird. That probably didn’t happen from normal handling.

Manual inspection catches some of this, but humans are inconsistent and slow. What if you could automatically flag suspicious patterns using spatial analysis?

That’s what we’re building here: a system that analyzes damage reports and detects physically improbable patterns using geometric reasoning.


The Big Picture

A shipping container is basically a box with 6 sides. We can divide it into zones:

Top View:
┌─────────────────┐
│  front-left  │  front-right  │
│─────────────────│
│  back-left   │  back-right   │
└─────────────────┘

Side View:
┌─────────────────┐
│  top-front   │
│  middle      │
│  bottom-front│
└─────────────────┘

Each damage report lists damaged sections (door, left panel, roof, etc.) with their locations. Our job is to look at the spatial distribution and ask: “Does this pattern make physical sense?”

We check for several suspicious patterns:

  1. Symmetric damage - Both left AND right sides damaged (very rare)
  2. Diagonal damage - Front-left AND back-right (physically unlikely)
  3. Impossible gaps - Front and back damaged, but middle sections untouched

Let’s build it.


Part 1: Grouping Damage by Location

First, we need to organize damage reports by their spatial location. Each container section has location tags like [:front, :left] or [:top, :middle].

defmodule ContainerDamage.PatternChecker do
  @moduledoc """
  Analyzes damage patterns in shipping containers to detect suspicious
  or physically improbable damage configurations.
  """

  def check_damage_plausibility(damage_reports) do
    # Filter to only damaged sections that have location data
    sections_with_locations =
      Enum.filter(damage_reports, fn report ->
        report.damaged? and not is_nil(report.locations)
      end)

    if Enum.empty?(sections_with_locations) do
      # No damage or no location data, nothing to check
      {:ok, :no_suspicious_patterns}
    else
      # Run all our pattern checks
      checks = [
        check_symmetric_pattern(sections_with_locations),
        check_diagonal_pattern(sections_with_locations),
        check_gap_pattern(sections_with_locations)
      ]

      suspicious = Enum.filter(checks, fn {score, _} -> score > 0.0 end)

      if Enum.empty?(suspicious) do
        {:ok, :no_suspicious_patterns}
      else
        {:suspicious, suspicious}
      end
    end
  end

  # Helper to filter sections by location tags
  defp filter_by_locations(sections, required_locations, excluded_locations \\ []) do
    Enum.filter(sections, fn section ->
      locations = section.locations || []

      has_required = Enum.all?(required_locations, &(&1 in locations))
      has_excluded = Enum.any?(excluded_locations, &(&1 in locations))

      has_required and not has_excluded
    end)
  end
end

The filter_by_locations/3 function is the workhorse here. It lets us ask questions like:

  • “Give me all sections that are both :front AND :left
  • “Give me sections that are :left but NOT :front or :back” (left-middle sections)

This is more flexible than hard-coding zone names because container designs vary. Some have 3 vertical sections, others have 2. The location tags adapt to any layout.


Part 2: Detecting Symmetric Patterns

Symmetric damage (both left AND right sides) is extremely rare in shipping. Containers usually get damaged on one side from a forklift, crane, or stacking issue. Both sides at once? Suspicious.

defp check_symmetric_pattern(sections) do
  # Group sections by quadrant
  by_location = %{
    front_left: filter_by_locations(sections, [:front, :left]),
    front_right: filter_by_locations(sections, [:front, :right]),
    back_left: filter_by_locations(sections, [:back, :left]),
    back_right: filter_by_locations(sections, [:back, :right]),
    # Sections that are just left or right (not front/back specific)
    left_only: filter_by_locations(sections, [:left], [:front, :back]),
    right_only: filter_by_locations(sections, [:right], [:front, :back])
  }

  patterns = detect_left_right_patterns(by_location)

  # Find all sections involved in suspicious patterns
  suspicious_sections =
    patterns
    |> Enum.filter(fn {_pattern_name, {is_suspicious?, _sections}} -> is_suspicious? end)
    |> Enum.flat_map(fn {_pattern_name, {_is_suspicious?, sections}} -> sections end)

  if Enum.empty?(suspicious_sections) do
    {0.0, []}
  else
    section_ids = Enum.map(suspicious_sections, & &1.id) |> Enum.uniq()
    {1.0, section_ids}  # Score of 1.0 = definitely suspicious
  end
end

defp detect_left_right_patterns(by_location) do
  %{
    # Pattern: Damage on BOTH front corners
    front_symmetric: {
      not Enum.empty?(by_location.front_left) and not Enum.empty?(by_location.front_right),
      by_location.front_left ++ by_location.front_right
    },
    # Pattern: Damage on BOTH back corners
    back_symmetric: {
      not Enum.empty?(by_location.back_left) and not Enum.empty?(by_location.back_right),
      by_location.back_left ++ by_location.back_right
    },
    # Pattern: Damage on both entire sides
    full_symmetric: {
      not Enum.empty?(by_location.left_only) and not Enum.empty?(by_location.right_only),
      by_location.left_only ++ by_location.right_only
    }
  }
end

The logic is straightforward: if we have damage on the left side AND the right side, flag it. We check three variations:

  • Both front corners
  • Both back corners
  • Both entire sides

Why three checks instead of one? Because the severity differs. Both front corners might happen if the container was dropped. Both entire sides? Almost impossible.

In a real system, you’d probably weight these differently. But for simplicity, we’re treating any symmetric pattern as equally suspicious.


Part 3: Detecting Diagonal Patterns

Diagonal damage is even weirder. Front-left AND back-right? How does that happen physically? The container would need to tumble in a very specific way, or be hit by two separate incidents.

defp check_diagonal_pattern(sections) do
  front_left = filter_by_locations(sections, [:front, :left])
  back_right = filter_by_locations(sections, [:back, :right])
  front_right = filter_by_locations(sections, [:front, :right])
  back_left = filter_by_locations(sections, [:back, :left])

  # Check both possible diagonals
  diagonal_1 = not Enum.empty?(front_left) and not Enum.empty?(back_right)
  diagonal_2 = not Enum.empty?(front_right) and not Enum.empty?(back_left)

  cond do
    diagonal_1 and diagonal_2 ->
      # Both diagonals damaged? VERY suspicious
      all_sections = front_left ++ back_right ++ front_right ++ back_left
      section_ids = Enum.map(all_sections, & &1.id) |> Enum.uniq()
      {1.0, section_ids}

    diagonal_1 ->
      section_ids = Enum.map(front_left ++ back_right, & &1.id) |> Enum.uniq()
      {1.0, section_ids}

    diagonal_2 ->
      section_ids = Enum.map(front_right ++ back_left, & &1.id) |> Enum.uniq()
      {1.0, section_ids}

    true ->
      {0.0, []}
  end
end

This one’s simple: if opposite corners are damaged, flag it. There’s not much nuance here—diagonal damage is pretty much always suspicious unless there’s a good explanation (like the container was in a serious accident, which would be documented).


Part 4: Detecting Gaps Using Graph Theory

This is where it gets interesting. Sometimes damage patterns have gaps that don’t make physical sense.

Imagine a container with damage on the front door AND the back panel, but the middle sections are completely fine. How did the force skip over the middle?

To detect this, we model the container as a graph:

  • Nodes = container sections (door, panel, roof, etc.)
  • Edges = physical adjacency (door connects to panel, panel connects to roof)

Then we calculate the “hop distance” between damaged sections. If two damaged sections are far apart (many hops) but all the sections in between are undamaged, that’s a gap.

defp check_gap_pattern(sections) do
  # Build a graph of container topology
  # (In production, this would come from a database of container types)
  topology = build_container_topology()

  # Find all pairs of damaged sections
  damaged_ids = Enum.map(sections, & &1.id)

  gap_pairs =
    for id1 <- damaged_ids,
        id2 <- damaged_ids,
        id1 < id2 do  # Avoid checking same pair twice

      # Calculate shortest path between these sections
      hop_distance = shortest_path_length(topology, id1, id2)

      # Get all sections along the shortest path
      path_sections = get_path_sections(topology, id1, id2)

      # Check if any intermediate sections are also damaged
      intermediate_damaged? =
        path_sections
        |> Enum.drop(1)  # Skip first (id1)
        |> Enum.drop(-1) # Skip last (id2)
        |> Enum.any?(&(&1 in damaged_ids))

      # A gap exists if:
      # 1. Sections are far apart (hop_distance > 1)
      # 2. No intermediate sections are damaged
      if hop_distance > 1 and not intermediate_damaged? do
        {:gap, id1, id2, hop_distance}
      else
        nil
      end
    end
    |> Enum.reject(&is_nil/1)

  if Enum.empty?(gap_pairs) do
    {0.0, []}
  else
    # Return all sections involved in gaps
    involved_ids =
      gap_pairs
      |> Enum.flat_map(fn {:gap, id1, id2, _} -> [id1, id2] end)
      |> Enum.uniq()

    {1.0, involved_ids}
  end
end

The graph approach is powerful because it handles any container layout. Add new section types? Just update the topology. Different container sizes? Same code works.

Building the Topology Graph

Here’s how you’d build the topology for a standard shipping container:

defp build_container_topology do
  # Define which sections connect to which
  # Format: {section_id, [connected_section_ids]}
  edges = [
    {"front_door", ["front_left_panel", "front_right_panel", "roof_front"]},
    {"front_left_panel", ["front_door", "middle_left_panel", "floor_front"]},
    {"front_right_panel", ["front_door", "middle_right_panel", "floor_front"]},
    {"middle_left_panel", ["front_left_panel", "back_left_panel"]},
    {"middle_right_panel", ["front_right_panel", "back_right_panel"]},
    {"back_left_panel", ["middle_left_panel", "back_door"]},
    {"back_right_panel", ["middle_right_panel", "back_door"]},
    {"back_door", ["back_left_panel", "back_right_panel", "roof_back"]},
    {"roof_front", ["front_door", "roof_middle"]},
    {"roof_middle", ["roof_front", "roof_back"]},
    {"roof_back", ["roof_middle", "back_door"]},
    {"floor_front", ["front_left_panel", "front_right_panel", "floor_middle"]},
    {"floor_middle", ["floor_front", "floor_back"]},
    {"floor_back", ["floor_middle", "back_door"]}
  ]

  # Convert to a graph structure (using :digraph or a library like libgraph)
  graph = Graph.new()

  Enum.reduce(edges, graph, fn {section, connections}, g ->
    g = Graph.add_vertex(g, section)

    Enum.reduce(connections, g, fn connected, g2 ->
      g2
      |> Graph.add_vertex(connected)
      |> Graph.add_edge(section, connected)
    end)
  end)
end

defp shortest_path_length(graph, from_id, to_id) do
  case Graph.dijkstra(graph, from_id, to_id) do
    nil -> :infinity  # No path exists
    path -> length(path) - 1  # Number of edges (hops)
  end
end

defp get_path_sections(graph, from_id, to_id) do
  Graph.dijkstra(graph, from_id, to_id) || []
end

For a standard 20-foot container, you might have 15-20 sections. A 40-foot container? Maybe 30. The graph scales well.


Part 5: Confidence Scoring

So far we’ve been returning 1.0 (suspicious) or 0.0 (fine). In reality, you probably want nuance.

Some patterns are more suspicious than others:

  • Both sides damaged → 70% confidence
  • Diagonal damage → 90% confidence
  • Gap with 3+ hops → 95% confidence

Here’s how to add confidence scoring:

defmodule ContainerDamage.ConfidenceScorer do
  def calculate_confidence(check_type, details) do
    case check_type do
      :symmetric ->
        # More sections = higher confidence it's suspicious
        num_sections = length(details.involved_sections)
        min(0.5 + (num_sections * 0.1), 1.0)

      :diagonal ->
        # Diagonals are almost always suspicious
        0.9

      :gaps ->
        # Longer gaps = higher confidence
        max_hop_distance = Enum.max(Enum.map(details.gaps, & &1.hop_distance))
        case max_hop_distance do
          1 -> 0.3  # Adjacent sections, might be ok
          2 -> 0.6  # One section gap, suspicious
          3 -> 0.85 # Two section gap, very suspicious
          _ -> 0.95 # Multiple sections skipped, almost certainly wrong
        end
    end
  end
end

You could also use machine learning to tune these thresholds based on historical fraud data. But starting with rule-based scoring is fine.


Part 6: Handling Edge Cases

Real-world data is messy. Here are gotchas I’ve encountered:

Missing location data

Not all damage reports have location data. Someone might report “door damaged” without specifying which door or which side.

defp filter_sections_with_locations(sections) do
  Enum.filter(sections, fn section ->
    section.locations && !Enum.empty?(section.locations)
  end)
end

Just skip these. You can’t do spatial analysis without spatial data.

Ambiguous locations

Some sections might be tagged [:left] without :front or :back. That’s fine—our location filter handles it with the exclusion parameter.

Multiple damage types

A section might have both a dent AND a scratch. For pattern detection, we don’t care about damage severity—only location. Just check if damaged? == true.

False positives

Sometimes legitimate damage looks suspicious. Container was in a major accident? Might have symmetric or diagonal damage.

Solution: treat these checks as flags for human review, not automatic rejections. An adjuster sees the flag and investigates.


Part 7: Putting It All Together

Here’s the complete flow:

defmodule ContainerDamage do
  alias ContainerDamage.{PatternChecker, ConfidenceScorer}

  def analyze_damage_report(report_id) do
    # Load damage report from database
    report = Repo.get!(DamageReport, report_id)
    sections = load_damaged_sections(report)

    # Run pattern checks
    case PatternChecker.check_damage_plausibility(sections) do
      {:ok, :no_suspicious_patterns} ->
        {:ok, %{suspicious?: false, patterns: []}}

      {:suspicious, patterns} ->
        # Add confidence scores
        scored_patterns =
          Enum.map(patterns, fn {_score, involved_ids} = pattern ->
            check_type = determine_check_type(pattern)
            confidence = ConfidenceScorer.calculate_confidence(check_type, pattern)

            %{
              check_type: check_type,
              confidence: confidence,
              involved_section_ids: involved_ids
            }
          end)

        # Sort by confidence (most suspicious first)
        sorted = Enum.sort_by(scored_patterns, & &1.confidence, :desc)

        {:suspicious, %{suspicious?: true, patterns: sorted}}
    end
  end

  defp load_damaged_sections(report) do
    from(s in ContainerSection,
      where: s.report_id == ^report.id,
      where: s.damaged == true,
      preload: [:section_type]
    )
    |> Repo.all()
  end
end

Example output:

{:suspicious, %{
  suspicious?: true,
  patterns: [
    %{
      check_type: :diagonal,
      confidence: 0.9,
      involved_section_ids: ["front_left_panel", "back_right_panel"]
    },
    %{
      check_type: :gaps,
      confidence: 0.85,
      involved_section_ids: ["front_door", "back_door"]
    }
  ]
}}

This gives adjusters a clear view: “This report has suspicious patterns. Review the highlighted sections.”


Testing Spatial Logic

Testing geometric algorithms can be tricky. Here’s a pattern that works well:

defmodule ContainerDamage.PatternCheckerTest do
  use ExUnit.Case

  alias ContainerDamage.PatternChecker

  describe "symmetric pattern detection" do
    test "flags damage on both left and right sides" do
      sections = [
        %{id: "left_1", locations: [:left, :front], damaged?: true},
        %{id: "right_1", locations: [:right, :front], damaged?: true}
      ]

      {score, involved_ids} = PatternChecker.check_symmetric_pattern(sections)

      assert score == 1.0
      assert "left_1" in involved_ids
      assert "right_1" in involved_ids
    end

    test "ignores damage on only one side" do
      sections = [
        %{id: "left_1", locations: [:left, :front], damaged?: true},
        %{id: "left_2", locations: [:left, :back], damaged?: true}
      ]

      {score, _ids} = PatternChecker.check_symmetric_pattern(sections)

      assert score == 0.0
    end
  end

  describe "gap detection" do
    test "flags damage with undamaged sections in between" do
      sections = [
        %{id: "front_door", locations: [:front], damaged?: true},
        %{id: "middle_panel", locations: [:middle], damaged?: false},
        %{id: "back_door", locations: [:back], damaged?: true}
      ]

      {score, involved_ids} = PatternChecker.check_gap_pattern(sections)

      assert score == 1.0
      assert "front_door" in involved_ids
      assert "back_door" in involved_ids
      refute "middle_panel" in involved_ids  # Not flagged because it's undamaged
    end
  end
end

Test with visual diagrams in comments:

test "complex gap scenario" do
  # Container layout:
  # [DAMAGED] - [ OK ] - [ OK ] - [DAMAGED]
  #    ^                              ^
  #    |------ 3 hop gap --------------|

  sections = [...]

  {score, _ids} = PatternChecker.check_gap_pattern(sections)
  assert score > 0.8  # High confidence for 3-hop gap
end

Wrapping Up

Spatial pattern detection is about encoding physical intuition into algorithms. Humans naturally think “that damage pattern doesn’t make sense”—we’ve just formalized that intuition.

Key takeaways:

  • Location-based filtering - Use flexible tags instead of hard-coded zones
  • Multiple checks - Look for different suspicious patterns (symmetric, diagonal, gaps)
  • Graph theory - Model physical adjacency to detect impossible gaps
  • Confidence scores - Not all suspicious patterns are equally suspicious
  • Handle edge cases - Real data is messy, filter and validate

This pattern works for any domain with spatial data:

  • Vehicle damage (the original use case this is based on)
  • Building inspections
  • Equipment damage assessments
  • Agricultural crop damage
  • Archaeological site analysis

The algorithms stay the same—just change the domain-specific topology.