DEV Community

Antti Haaraniemi
Antti Haaraniemi

Posted on

Building On-Premises AI Analytics for Industrial Automation with Ollama

TL;DR: We built local LLM-powered analytics for industrial OPC UA data using Ollama, ASP.NET Core, and TimescaleDB — enabling anomaly detection, forecasting, and cost optimization without sending sensitive process data to the cloud.


The Problem: Cloud AI Doesn't Work for Industry

When you're running a power plant, district heating network, or manufacturing facility, your process data is:

  • Sensitive — production metrics, energy consumption, equipment performance
  • High-volume — 2000+ measurement points per second, 172 million rows per day
  • Network-constrained — industrial networks are often air-gapped or firewalled
  • Compliance-critical — GDPR, sector-specific regulations, trade secrets

Cloud-based AI analytics mean:

  1. Uploading gigabytes of time-series data daily
  2. Monthly subscription fees that scale with data volume
  3. Latency (query → cloud → response takes seconds, not milliseconds)
  4. Vendor lock-in and data residency concerns

What if you could run GPT-class AI models locally, on the same server as your time-series database?

That's what we built with DataPortia — an industrial data acquisition system with on-premises AI analytics powered by Ollama.

Tech Stack

  • Data Collection: OPC UA .NET Standard SDK (1.5.376)
  • Backend: ASP.NET Core 9.0
  • Database: PostgreSQL 18 + TimescaleDB (hypertables, continuous aggregates, compression)
  • AI: Ollama (local LLM server) with models: Qwen3, DeepSeek-R1, Gemma3, Llama 3
  • Data Access: Npgsql (binary COPY for batch inserts), EF Core 9, Dapper
  • Frontend: Vanilla JavaScript + ECharts 5.6 for visualizations

Five Types of AI Analysis (Without Cloud)

We implemented 5 analysis types that industrial automation engineers actually need:

1. Anomaly Detection (Z-score + severity classification)

"Which measurement points are behaving abnormally?"

  • Computes statistics: min, max, avg, median, stddev
  • Identifies outliers using Z-score
  • Classifies severity: Critical (Z>3), Warning (Z>2), Minor (Z>1)

2. Forecasting (Trend + periodicity analysis)

"Will this equipment fail? Is energy consumption rising?"

  • Linear regression on time-series data
  • Detects seasonal patterns
  • Predicts future values based on historical trends

3. Cost Optimization (Energy waste identification)

"Where can we save energy costs?"

  • Compares baseline vs. actual consumption
  • Identifies idle equipment, inefficient cycles
  • Calculates kWh waste and monetary impact

4. Report Generation (Structured 6-section analysis)

"Summarize this week's production data"

  • Key metrics summary
  • Trend analysis
  • Problem identification
  • Root cause analysis
  • Recommendations
  • Action items

5. Natural Language Queries (Chat interface)

"Why did boiler temperature spike at 14:35 yesterday?"

  • Multi-turn conversations with context history
  • Combines time-series data with facility descriptions
  • Markdown-formatted responses with charts

Implementation Deep Dive

1. Service Architecture (ASP.NET Core DI)

// Program.cs — Dependency Injection
builder.Services.AddSingleton<OllamaHealthCheckService>();
builder.Services.AddSingleton<PromptBuilder>();
builder.Services.AddScoped<AiAnalysisService>();

builder.Services.Configure<AiAnalysisOptions>(
    builder.Configuration.GetSection("AiAnalysis")
);
Enter fullscreen mode Exit fullscreen mode

// config.ini
[AiAnalysis]
OllamaUrl = http://localhost:11434
DefaultModel = qwen3:4b
MaxTokens = 8192
TimeoutSeconds = 600
MaxConcurrentRequests = 3

Key services:

  • OllamaHealthCheckService (Singleton, IHostedService) — 60-second health check, caches model list
  • PromptBuilder (Singleton) — Builds system prompts with data context (quadlingual: fi/en/sv/de)
  • AiAnalysisService (Scoped) — Orchestrates data fetch → statistics → Ollama API → result persistence

2. Data Pipeline: TimescaleDB → Statistics → Prompt

// AiAnalysisService.cs
public async Task<AiAnalysisResult> RunAnalysisAsync(
    AnalysisRequest request, 
    CancellationToken ct)
{
    // 1. Fetch time-series data via DataRetrievalService
    var (rawData, aggregated) = await _dataRetrievalService
        .GetDataWithAggregation(
            request.StartTime, 
            request.EndTime, 
            request.MinuteGroup, 
            tagsJson
        );

    // 2. Compute statistics (min/max/avg/median/stddev/outliers/trend)
    var statistics = ComputeStatistics(rawData, aggregated);

    // 3. Query measurement point units from SelectedData table
    var tagUnits = await GetTagUnits(request.Tags);

    // 4. Build system prompt with data context
    var systemPrompt = _promptBuilder.BuildSystemPrompt(
        request.AnalysisType,
        aggregated,
        sampleData: rawData.Take(100), // Prevent token overflow
        request.StartTime,
        request.EndTime,
        request.Language,
        facilityContext: facilityDescriptions,
        tagUnits: tagUnits
    );

    // 5. Call Ollama /api/chat
    var aiResponse = await CallOllamaAsync(
        systemPrompt, 
        request.UserPrompt, 
        ct
    );

    // 6. Save session to database for history
    await SaveSessionAsync(request, aiResponse, statistics);

    return new AiAnalysisResult { Success = true, ... };
}
Enter fullscreen mode Exit fullscreen mode

3. Ollama API Integration (SSE Streaming)

We use Server-Sent Events (SSE) for real-time streaming responses to the frontend:

// AiController.cs
[HttpPost("analyze/stream")]
public async IAsyncEnumerable<string> AnalyzeStream(
    [FromBody] AnalysisRequest request,
    [EnumeratorCancellation] CancellationToken ct)
{
    // Send progress event
    yield return JsonSerializer.Serialize(new {
        type = "status",
        message = "Fetching data..."
    });

    // ... (data fetch + statistics)

    // Stream Ollama response
    await foreach (var chunk in _aiService.StreamAnalysisAsync(
        request, ct))
    {
        yield return JsonSerializer.Serialize(new {
            type = "chunk",
            content = chunk
        });
    }

    // Send completion event with sessionId
    yield return JsonSerializer.Serialize(new {
        type = "done",
        sessionId = session.Id,
        statistics = statistics
    });
}
Enter fullscreen mode Exit fullscreen mode

Ollama HTTP client:

private async IAsyncEnumerable<string> CallOllamaStreamingAsync(
    string systemPrompt,
    string userMessage,
    CancellationToken ct)
{
    var client = _httpClientFactory.CreateClient("Ollama");
    var payload = new {
        model = ActiveModel,
        messages = new[] {
            new { role = "system", content = systemPrompt },
            new { role = "user", content = userMessage }
        },
        stream = true,
        options = new {
            temperature = 0.3,
            num_predict = IsThinkingModel(ActiveModel) 
                ? _options.MaxTokens * 2 
                : _options.MaxTokens,
            repeat_penalty = IsQwenModel(ActiveModel) ? 1.05 : 1.2,
            repeat_last_n = IsQwenModel(ActiveModel) ? 256 : 128
        }
    };

    var response = await client.PostAsJsonAsync(
        "/api/chat", payload, ct
    );

    await using var stream = await response.Content
        .ReadAsStreamAsync(ct);
    using var reader = new StreamReader(stream);

    while (!reader.EndOfStream)
    {
        var line = await reader.ReadLineAsync(ct);
        if (string.IsNullOrWhiteSpace(line)) continue;

        var chunk = JsonSerializer.Deserialize<OllamaStreamChunk>(line);
        if (chunk?.Message?.Content != null)
        {
            yield return chunk.Message.Content;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Prompt Engineering for Industrial Data

The PromptBuilder creates context-aware prompts with:

  • Facility descriptions (equipment type, capacity, process flow)
  • Measurement point statistics (with units!)
  • Trend directions (increasing ↑ / stable — / decreasing ↓)
  • Sample data points (up to 100 to prevent token overflow)
  • Analysis type instructions (anomaly/forecast/cost/report/chat)

Example system prompt (abbreviated):

You are an industrial data analyst expert specializing in process
automation, energy systems, and manufacturing optimization...

FACILITY CONTEXT:

Power Plant "Main Boiler #1" (District Heating, 50 MW, 3 boilers)
Tags: boiler_temp, steam_pressure, fuel_flow
MEASUREMENT POINT STATISTICS (2025-03-10 00:00 to 2025-03-15 23:59):
DataPortia - Measurement point statistics
SAMPLE DATA (100 points):
2025-03-15 23:45 | boiler_temp=188.2°C | steam_pressure=9.1bar | ...

TASK — ANOMALY DETECTION:
Analyze the measurement points above:

  1. Identify anomalies using Z-score analysis
  2. Classify severity: Critical (Z>3), Warning (Z>2), Minor (Z>1)
  3. Explain potential root causes
  4. Recommend corrective actions

Respond in English with markdown formatting.

5. Thinking Models (Qwen3, DeepSeek-R1, QwQ)

Some LLMs output reasoning in <think>...</think> blocks. We auto-detect and strip them:

private bool IsThinkingModel(string model)
{
    return model.Contains("qwen3", StringComparison.OrdinalIgnoreCase) ||
           model.Contains("deepseek-r1", StringComparison.OrdinalIgnoreCase) ||
           model.Contains("qwq", StringComparison.OrdinalIgnoreCase);
}

// Append /no_think to model name for Ollama API
var modelName = IsThinkingModel(ActiveModel) 
    ? $"{ActiveModel}/no_think" 
    : ActiveModel;

// Strip <think> blocks from response
var cleanContent = Regex.Replace(
    aiResponse, 
    @"<think>.*?</think>", 
    "", 
    RegexOptions.Singleline
);
Enter fullscreen mode Exit fullscreen mode

Challenges and Solutions

Challenge 1: Token Limits

Problem: TimescaleDB can return millions of rows — LLMs have ~8K-32K token limits.

Solution:

  • Compute statistics server-side (min/max/avg/stddev/outliers)
  • Send only 100 sample data points to LLM
  • Use continuous aggregates (minute/hourly/daily) instead of raw data

Challenge 2: Numeric Precision

Problem: Industrial sensors output double values → NaN, Infinity break JSON serialization.

Solution:

private static double SanitizeDouble(double value)
{
    if (double.IsNaN(value) || double.IsInfinity(value))
        return 0.0;
    return value;
}

// JsonSerializerOptions for statistics
var options = new JsonSerializerOptions {
    NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals
};
Enter fullscreen mode Exit fullscreen mode

Challenge 3: Concurrency Control

Problem: Multiple users triggering AI analysis → Ollama server overload.

Solution:

private static SemaphoreSlim _concurrencySemaphore = 
    new SemaphoreSlim(3, 3); // Max 3 concurrent requests

public async Task<AiAnalysisResult> RunAnalysisAsync(...)
{
    await _concurrencySemaphore.WaitAsync(ct);
    try
    {
        // ... AI analysis
    }
    finally
    {
        _concurrencySemaphore.Release();
    }
}
Enter fullscreen mode Exit fullscreen mode

Challenge 4: Model Availability

Problem: User selects model, downloads it via ollama pull, app needs to detect new models.

Solution: Background health check service (60s interval):

// OllamaHealthCheckService.cs
protected override async Task ExecuteAsync(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        var response = await _client.GetAsync("/api/tags", ct);
        var models = JsonSerializer.Deserialize<OllamaTagsResponse>(
            await response.Content.ReadAsStringAsync(ct)
        );

        lock (_modelsLock) {
            _cachedModels = models.Models;
        }

        _isOllamaAvailable = true;
        await Task.Delay(TimeSpan.FromSeconds(60), ct);
    }
}
Enter fullscreen mode Exit fullscreen mode

Performance & Capacity

Real-world metrics from a district heating network (10 connections, 200 tags each):

DataPortia - Performance table


Why On-Premises AI Matters for Industry

Data privacy: Process data never leaves the local network. GDPR compliance is simpler.

Zero cloud costs: No per-API-call fees. Run unlimited analyses. Model downloads are free.

Low latency: LAN query times (<50ms) vs. cloud round-trips (500ms+).

Offline operation: Industrial sites with limited internet connectivity can still use AI.

Customization: Fine-tune models on site-specific data without uploading to third parties.


Try It Yourself

DataPortia is available as a free 30-day trial:
👉 https://atorcom.fi/en/dataportia-opc-ua-reporting-software

Requirements:

  • Windows 10/11 or Windows Server 2016+ (Linux supported)
  • PostgreSQL 18 + TimescaleDB (included in installer)
  • Ollama installed separately: https://ollama.ai
  • Recommended models: ollama pull qwen3:4b or ollama pull gemma3:4b

For developers:


What's Next?

We're exploring:

  • RAG (Retrieval-Augmented Generation) for equipment manuals and maintenance logs
  • Fine-tuning local models on 24 months of historical data (domain-specific predictions)
  • Edge deployment on Raspberry Pi / industrial PCs running at substations
  • Multi-agent workflows (one agent for anomaly detection, another for root cause analysis)

Closing Thoughts

Cloud AI is powerful, but not every use case belongs in the cloud. For industrial automation, on-premises AI with Ollama offers:

  • ✅ Data sovereignty
  • ✅ Cost predictability (no per-token pricing)
  • ✅ Low latency
  • ✅ Offline resilience

And with modern .NET performance + TimescaleDB compression + Ollama's efficient inference, you can run GPT-class analytics on modest hardware.

Have you built on-premises AI for industrial/IoT applications? Drop a comment — I'd love to hear about your architecture and challenges!


About the Author

Building industrial data acquisition software at Atorcom (Finland). Interested in ASP.NET Core, OPC UA, TimescaleDB, and local-first AI. Connect: antti.haaraniemi@atorcom.fi


This article is based on real production code from DataPortia v2.22.0. All code snippets are simplified for readability but reflect actual implementation patterns.

Top comments (0)