2021년 2월 23일 화요일

디지털트윈 구현을 위한 오픈소스 기반 IoT, MongoDB, BIM과 Autodesk Forge 연결 개발 방법

이 글은 IoT(Internet Of Things), NoSQL MongoDB, BIM(Building Information Modeling)과 Autodesk Forge 상호연결 방법에 대한 간단한 설명이다. 개발 환경은 node.js, express, bootstrap을 사용해 앱 서버로서 실행된다. 
이 글에서 소개하는 기술은 디지털 트윈 개념 구현에 참고할 수 있으며, 손쉽고 저렴한 기술 개발을 위해, 오픈소스를 적극 사용한다. 동기 부여를 위해, 이 글에서 개발될 시스템명은 DTB-BMS(가칭 Digital Twin and BIM based Building Management System)으로 한다. 디지털 트윈 개념적 이야기는 아래 글을 참고한다.
쉬운 개발을 위해, IoT 센서 데이터 취득 및 DB 연결은 node-red를 사용한다, 굳이 성능 최적화가 중요하다면, 파이썬 등 다른 언어를 사용해 코딩한다. 참고로, 이 글은 간단히 메이크하는 것을 지향하므로, 굳이 어려운 방법을 사용하지는 않는다.

이 글의 개발 파일 및 소스 코드는 블로그에 싣기에는 많고 복잡할 수 있다. 그러므로, 주요 개발 컨셉과 코드를 중심으로 설명한다. 관련 코드들을 이 github링크의 해당 폴더들을 참고하길 바란다. git을 설치하면, 간편하게 개발된 코드를 다운로드할 수 있다. 참고로, 개발 결과 화면은 다음과 같다. 이와 관련된 Mongo DB, node.js, IoT 장치 개발 등 다른 내용들이 궁금하다면, 이 블로그의 관련 글들을 참고한다. 

DTB-BMS(Digital Twin and BIM based Building Management System) APP server screen and database REST API(Application Program Interface)

DTB-BMS Demo

DTB-BMS 시스템 요구사항 및 패키지 디자인
막코딩이 싫다면 목적에 맞게 시스템 아키텍처를 설계하는 것은 중요하다. 이 시스템의 핵심 요구사항은 다음과 같다.
  • BIM 모델의 각 공간에 설치한 IoT 센서 데이터 모니터링
  • 분석을 위한 각 공간별 센서 데이터 취득 지원 
  • 소형화된 센서의 간편한 설치
  • 공간별 센서 데이터의 주요 부분 그래프 지원
  • 데이터 모델 확장성이 쉬운 구조
  • 유지보수성 고려한 DB(Database), API, 비지니스 로직 분리
  • 50만원 이하 매우 저렴한 비용으로 앱, DB, API 서버 구현
시스템 패키지 구조는 다음과 같다. 
디지털 트윈 시스템 기본 아키텍처(UML)

IoT 센서 개발
아두이노 나노 센스를 이용해, BIM 공간 관리를 위한 데이터를 얻는다. 핵심 아두이노 코드는 다음과 같다. Serial port로 출력하며, 출력 목적지는 초기 설정에 따라 WiFi, BLE, 유선이 가능하도록 한다.
void sensingNano(void) {
  display.clearDisplay();

  float temperature = HTS.readTemperature();
  float humidity    = HTS.readHumidity();
  float pressure = BARO.readPressure();

  // print each of the sensor values
  Serial.print(temperature);
  Serial.print(", ");
  Serial.print(humidity);
  Serial.print(", ");
  Serial.print(pressure);
  
  display.setCursor(0, 24);
  String data = String("THP: ") + String(temperature, 1) + ", " + String(humidity, 1) + ", " + String(pressure, 1);
  display.println(data);
  ...
}

구현 코드는 다음 링크를 참고한다.
구현 결과는 다음과 같다. 

관련해 자세한 개발 방법은 아래 링크를 참고한다.
참고로, BLE 버전은 센서가 많으나 블루투스만 지원한다. 데이터를 몽고디비에 저장하기 위해서 인터넷 연결이 필요하다. 이는 Serial port를 통해 node-red에서 데이터를 받아 전송하거나, WiFi회로를 만들어 데이터를 전송한다.
Serial port 로 데이터 전송시에는 WiFi 브릿지를 라즈베리파이(RPi)같은 보드를 이용해 데이터를 받은 후 WiFi로 전송해야 한다. 이 경우 라즈베리파이는 게이트웨이 역할을 한다. 다음은 이와 관련된 연결이다.

Real world - Arduino BLE sense - RPi - WiFi - DB server

WiFi는 다음 예를 참고해 ESP8266 과 같은 Wifi Bridge를 사용한다. 
노드레드 활용 Mongo DB에 IoT 데이터 전송 및 연결 프로토콜
IoT 장치에서 보낸 데이터를 MongoDB에 전송하기 위해 node-red를 사용한다. node-red에서 다음과 같이 function노드를 만들어 프로그래밍한다.
노드레드 기반 센서 데이터베이스 저장을 위해 코딩된 그래프

프로그래밍 된 코드는 다음 링크를 참고한다.
상호연결을 위한 통신 프로토콜이 필요하다. 노드래드 그래프에서 첫번째 함수는 아두이노와 연결된 COM 포트에서 콤마 형식으로 구분된 IoT 데이터들(예. "608.02, 910.13, 7203.23, ...")을 개별 토큰(token)으로 분리한다. 두번째 함수는 토큰으로 분리된 값들을 각각 mongo DB 노드로 전송한다. 여기에, 필요하다면 IoT가 설치된 Room, Element 등 ID를 추가한다.

첫번째 함수 노드 스크립트는 다음과 같다.
var output = msg.payload.split(",");

var msgList = [];
output.forEach(function(v, index)
{
    var msgIoT = {payload: parseFloat(v)};
    msgList.push(msgIoT);
});

return msgList;

두번째 함수 노드 스크립크를 다음과 같이 코딩한다. area는 센서가 설치된 공간을 구분하기 위한 분류체계로 사용될 것이다.
var d = new Date();
var date = d.getFullYear() + '-' + d.getMonth() + '-' 
        + d.getDate() + '-' + d.getHours() + ':' + d.getMinutes()
        + ':' + d.getSeconds();
            
var newMsg = {
            'sensor': 'temp',
            'area' : 'Skinner',
            'date' : date,                    
            'value': msg.payload
            };
 
return newMsg;

다음과 같이 노드레드에서 Mongo DB 노드를 생성해, 데이터베이스와 연결하면 된다. 
node-red의 MongoDB 설정 화면

데이터베이스 및 BIM 정의
1. 데이터베이스 모델 스키마 정의
먼저 MongoDB를 설치한다(MongoDB에 대한 사용법은 이 링크를 참고한다).
센서 데이터베이스 모델링을 위해, 터미널에서 mongo를 실행하고, 다음과 같이 정의한다.
show dbs
use sensor
db.records.insert({sensor:'light', area:'UNF', date:'2021-1-1', value:31})
Mongo DB 터미널에서 데이터베이스 정의 결과

MongoDB compass에서 확인한 결과는 다음과 같다. 

이제 MongoDB 외부 네트워크 접속을 위해 아래 설정 파일을 설치 프로그램 폴더에서 확인해 수정한다.
윈도우즈: C:\Program Files\MongoDB\Server\4.4\bin\mongod.cfg
리눅스: /etc/mongod.conf

파일을 열어 다음과 같이 Ip를 0.0.0.0으로 수정한다. 그리고 몽고디비를 재시작한다.
# network interfaces
net:
  port: 27017
  bindIp: 0.0.0.0

2. 데이터베이스 모델 용량 계산
현재는 온도만 저장하고 있으나, 센서 유형별로 다양한 데이터 값을 저장하기 위해 sensor 컬럼을 추가하였다. 테스트에서는 온도 센서값만 저장하고 있다. 1년동안 서비스한다는 가정하에 어느 정도 저장 용량 장치가 필요하지 확인하기 위해, 다음과 같이 저장 용량을 계산한다.
데이터베이스 향후 저장 용량 계산

1년을 저장한다면, 7,388.94 Mb 저장 용량이 필요하다. 센서 유형을 온도 이외에 습도, 조도, 사운드 마이크로폰 추가시 약 30 Gb 저장 용량이면 된다. 1년 간 데이터 백업을 고려하면 60 Gb이다.  저장소가 부족하 경우, 압축이나 데이터 취득 간격 조정을 고려한다. 참고로, IoT장치에서 1초에 2번씩 데이터를 샘플링하므로, 1초로 줄일 경우, 계산된 저장용량의 절반 정도면 충분할 것이다. 참고로, 신호처리이론(Nyquist–Shannon sampling theorem, Sampling and sampling density)에 의해 데이터 처리 간격이 1/1초라면, 데이터 손상 없는 재현을 위해, 최소 두배(2.56) 간격으로 샘플링하는 것이 좋다. 아울러, 샘플링은 센서 스펙도 함께 고려한다(예. Arduino Nano BLE sense 33. Sensor output rate = 104 Hz).

3. BIM 모델 LoD 경량화
BIM 모델링은 Autodesk Revit을 이용한다. 모델 LoD(Level of Detail) 경량화 과정은 모델 용량에 따라 매우 많은 노가다가 필요할 수 있다. 사용된 BIM 모델은 UNF 건물 일부이다. 중앙 저장소 파일 모델 하나의 크기만 110Mb가 넘어간다. 연결된 파일들을 모두 합치면 500Mb가 넘어간다. 이 상태로는 웹에 모델을 띄울 수도 없을 뿐더러, 뛰운다고 하더라도 불필요한 정보가 너무 많아 모델 정보 사용이 매우 불편해진다. 
Revit으로 해당 모델을 띄우고, 평면 뷰에서 세션을 만들어 요구사항과 관련없는 층들은 모두 삭제한다. 패밀리에서 가구, MEP 등 불필요한 유형은 모두 삭제한다. 실수로 삭제된 요소는 해당 부분만 다시 모델링해준다. 기타, 사용안되는 도면 등 요소들도 삭제한 후 Purge로 정리한다. 모델이 복잡하거나 요구사항이 불명확하면, 이런 경량화 작업이 몇시간에서 몇일 걸릴 수도 있다. 
UNF 대학 Skinner 건물 일부

이러한 경량화 작업으로 모델을 다음과 같이 20Mb로 줄였다.  
LoD 경량화된 BIM 결과
LOD 경량화된 BIM 모델 WEB 렌더링 결과

단, 업로드 시 사용하는 Axios 기본 설정은 5Mb이므로, 이를 다음과 같이 적절히 수정해야, 대용량 BIM 모델을 업로드할 수 있다. 
        Axios({
            method: 'PUT',
            maxContentLength: 100000000,  // 최대 BIM 모델 크기 정의
            maxBodyLength: 1000000000,
        })
참고로, 업로드된 래빗 파일은 SVG형태로 변환되어 버킷에 저장된다. 변환 시 입력 모델이 잘못되어 있다면, SVG로 파싱되지 않으며, 모델이 제대로 렌더링되지 않는다.

4. 모델-데이터베이스 Connection 
BIM 모델 경량화 후 IoT 데이터 연결 과정을 작업한다. 각 레코드는 IoT 데이터와 연결을 위해서는 모델 객체 별로 연결 데이터 처리 작업이 필요하다. BIM 객체에 센서 데이터 연결을 위한 ID같은 연결점을 모델링한다(마법은 없다. 눈으로 객체를 확인하며 손으로 직접 입력 수정한다. 참고로, 분류체계가 잘 되어있다고 하더라도, 분류체계 자체가 유일ID가 아니면 어차피 눈으로 보고 손으로 직접 입력해야 함. 래빗 애드인이나 스크립트를 통해 이 과정을 좀 간소화시킬 수는 있을 것이다). 상호 연결을 위해 객체 속성 GUID, 분류체계, 태그 ID 등을 생성하고 이를 이용해 DB, 비지니스 로직과 연결한다. 연결할 ID가 상호 잘못되면 제대로 처리가 안된다.
Autodesk Revit의 Room 속성 예시
BIM 공간 - IoT 데이터 연결 ID 속성값 예시(Room face for model using NWC)
BIM 객체 속성값에 의한 IoT 데이터 연결 예시

참고로, 독자는 테스트를 위해 Autodesk 제공 예제 파일을 이용하기를 바란다.

MongoDB CRUD REST API 서버 개발
몽고디비 리소스는 보안 등의 문제로 인터넷 브라우저에서 직접 접속할 수 없다. 데이터를 얻기 위해, REST API서버를 개발한다. 주요 코드는 다음과 같다.
// sensor.mode.js 파일
const mongoose = require('mongoose');  // 몽고DB 연결을 위해 mongoose node 패키지 사용

var Schema = mongoose.Schema, ObjectId = Schema.ObjectId;

// 저장되는 몽고DB 스키마 구조 정의
const RecordSchema = new Schema({
    _id : ObjectId, 
    sensor : String,
    area : String,
    date : String,
    value : Number
    // _msgid : String
}); 

const record = mongoose.model('record', RecordSchema);
module.exports = record;

// sensor.routes.js 파일
module.exports = (app) => {
    const sensors = require('../controllers/sensor.controller.js');

    // Retrieve all sensors
    app.get('/sensors', sensors.findAll);  // 센서 데이터를 얻는 URL GET 함수
    ...

// sensor.controll.js 파일
const Record = require('../models/sensor.model.js');

// 데이터베이스에서 데이터 검색해 리턴.
exports.findAll = (req, res) => {
    Record.find()
    .then(records => {
        res.send(records);
    }).catch(err => {
        res.status(500).send({
            message: err.message || "Some error occurred while retrieving Record."
        });
    });
};
...

데이터 검색 결과에 조건을 적용하고 싶을 때가 있다. 다음과 같은 REST API query 질의를 처리하려면, req 페이지 요청 객체(Express Request)에서 해당 질의를 파싱해 몽고디비 find() 함수에 검색 조건을 입력해야 한다. 
http://localhost:4000/sensors?limit=10&date=2021
REST API 조건 질의 검색 결과

다음은 검색 조건을 고려한 데이터베이스 검색 코드 루틴이다.
exports.findAll = (req, res) => {
    var query = {};
    if(typeof req.query.date != 'undefined') {
        query = {date: {$gt: req.query.date}};  // {date: {$gt: '2021-2-3-17:32'}}
    }

    try {
            Record.find(query).then(records => {
                res.send(records);
            });
        }

실제 구현 코드는 아래 링크를 참고한다.
관련 내용의 자세한 설명은 다음 링크를 참고한다.
개발 결과는 다음과 같다.
센서 데이터 REST API 결과

Postman 도구를 이용하면, 개발된 REST API를 쉽게 테스트할 수 있다(참고. Postman 설치). Postman에서 GET URL을 http://localhost:4000/sensors로 입력한다. 다음 그림과 같은 결과가 출력되면 성공한것이다.
Postman REST API 테스트 결과

참고로, REST API서버는 이를 이용하는 포지 앱 서버와 서로 CORS 설정이 되어 있어야, 에러 없이 리소스에 접근할 수 있다. 구현 코드는 이를 기본 옵션으로 설정해 두었다. 관련 자세한 내용은 다음 링크를 참고한다. 
Autodesk Forge와 Mongo DB 연결 및 데쉬보드 개발
이제, Autodesk Forge와 Mongo DB를 연결하고, 데쉬보드 개발한다. BIM Forge view 개발 방법은 다음을 참고한다.
실제, 구현 코드는 다음 링크를 참고한다.
구현 코드의 폴더 구조는 다음과 같다.
구현 코드에서 포지 앱과 몽고 DB연결은 앞서 개발한 API 서버를 사용한다. 주요 코드는 다음과 같다.
//  viewer.html 파일
<!DOCTYPE html>
<html>
...
<head>
</head>
...
<body>
    <script type="text/javascript">
        function renderSensorTable(responseText) {
            var htmlElement= document.getElementById('sensorDataTable'); 
            htmlElement.innerHTML = '';

            var table = document.createElement("table");
            table.setAttribute("class", "table table-striped table-sm table-responsive");

            var head = "<thead><tr><th>No</th><th>Area</th><th>Sensor</th><th>Time</th><th>Value</th></tr></thead>";
            table.innerHTML = head;   // 테이블 헤더 정의

            var tbody = document.createElement("tbody");
                           
            var records = JSON.parse(responseText);  // REST API 리턴 데이터를 JSON 포맷으로 변환
            for (var i = 0; i < records.length; i++) {    // 센서 데이터 레코드 행 추가
                var record = records[i];

                var recValues = [];
                recValues.push(i);
                recValues.push(record.area);
                recValues.push(record.sensor);
                recValues.push(record.date);
                recValues.push(record.value);               

                var row = document.createElement("tr");

                for (var j = 0; j < recValues.length; j++)  // 센서 데이터 레코드 값 추가
                {
                    var c = document.createElement("td");
                    var t = document.createTextNode(recValues[j]);
                    c.appendChild(t);
                    row.appendChild(c);                    
                }

                tbody.appendChild(row);
            } 
            table.appendChild(tbody);
            htmlElement.appendChild(table);
        }

        function renderSensorData() {  // 센서 데이터 렌더링
            // Create a new ajax requst
            var oReq = new XMLHttpRequest();  // REST API 요청

            oReq.onreadystatechange = function ()  // 비동기 데이터 처리
            {
                if (oReq.readyState == 4 && oReq.status == 200)
                    renderSensorTable(oReq.responseText);  // IoT 데이터 전송 시 처리
            };

            // Create the connection to our API
            oReq.open("GET", "http://localhost:4000/sensors");  // REST API를 통한 IoT 센서 데이터 요청
            oReq.setRequestHeader('Content-Type', 'application/json');
            oReq.send();    // Fire the request
        }

        /**
        * Autodesk.Viewing.Document.load() success callback.
        * Proceeds with model initialization.
        */
        function onDocumentLoadSuccess(doc) {
            // Load the default viewable geometry into the viewer.
            // Using the doc, we have access to the root BubbleNode,
            // which references the root node of a graph that wraps each object from the Manifest JSON.
            var viewable = doc.getRoot().getDefaultGeometry();
            if (viewable) {
                viewer.loadDocumentNode(doc, viewable).then(function(result) { // 3D 뷰어 로딩시 발생 이벤트
                    console.log('Viewable Loaded!');
                    viewer.loadExtension("NestedViewerExtension", { filter: ["2d"], crossSelection: true });  

                    renderSensorData();  // 센서 데이터 렌더링
                }).catch(function(err) {
                    console.log('Viewable failed to load.');
                    console.log(err);
                }
              )
            }
        }
  </script>
</body>
</html>

데쉬보드 그래픽은 Bootstrap을 사용하며, 뷰어 우측과 아래에 차트 그래프(forgeViewer div 요소 뒤에 추가됨), 테이블(sensorDataTable)로 동적 구성된다. 동적 구성될 요소는  viewer.html의 body에서 다음과 같이 정의되어 있다(Ex. table event).
<body>
    <!-- Custom script -->
    <h4>Digital Twin based Building Monitoring and Data Analysis</h4>
    <div class="container-fluid fill">
        <div class="row fill">               
            <div class="col-sm-7 fill">          
                <div id="forgeViewer"> 
                </div>
            </div> 
        </div>
    </div>        
    <div class="container-fluid fill">
        <div class="row">       
            <div class="col-sm-12 fill">
                <button id="reflashSensorData" onclick="renderSensorData()">Reflash IoT sensor data</button>                  <div id="sensorDataTable" class="table-responsive"></div> 
            </div>             
        </div>
    </div>

그래프 동적 구성을 위한 주요 코드는 다음과 같다. 데쉬보드 div 요소가 추가되고, 데쉬보드 div 요소 안에 바차트, 파이차트 div 요소가 추가된다.
// dashboard.js
$(document).ready(function () {
    $(document).on('DOMNodeInserted', function (e) {
        if ($(e.target).hasClass('orbit-gizmo')) {
            new Dashboard(NOP_VIEWER, [new BarChart('Light'), new PieChart('Temp')]);  // HTML DOM ready 이벤트 발생 시 데쉬보드 추가.    
        }
    });
})
class Dashboard {
    adjustLayout() {
        var row = $(".row").children();
        if(row.length > 0)
            $(row[0]).removeClass('col-sm-7').addClass('col-sm-9 transition-width').after('<div class="col-sm-3 transition-width" id="dashboard"></div>');  // Forge viewer div 아래에 차트 그래프 요소 추가
    }
}

// Dashboard panel base
class DashboardPanel {
    load(parentDivId, divId, viewer) {
        this.divId = divId;
        this.viewer = viewer;
        $('#' + parentDivId).append('<div id="' + divId + '" class="dashboardPanel"></div>');  // 데쉬보드 요소 추가
    }
}

// Dashboard panels for charts
class DashboardPanelChart extends DashboardPanel {
    load(parentDivId, divId, viewer) {
        divId = this.propertyToUse.replace(/[^A-Za-z0-9]/gi, '') + divId; // div name = property + chart type
        super.load(parentDivId, divId, viewer);
        this.canvasId = divId + 'Canvas';
        $('#' + divId).append('<canvas id="' + this.canvasId + '" width="400" height="400"></canvas>');  // 차트 그래프 요소 추가
        return true;
    }
}

// 바차트 데이터 렌더링 구현
class BarChart extends DashboardPanelChart {
    drawChart() {
    ..
    }
}

// 파이차트 데이터 렌더링 구현
class PieChart extends DashboardPanelChart {
    drawChart() {
   }
}

이 코드의 실행 결과로 다음과 같이 HTML DOM(document object model)이 구성된다. 앱 화면 레이아웃은 목적에 맞게 CSS파일의 해당 요소 스타일을 적절히 수정한다.
DOM 구조
데쉬보드 화면

BIM 객체 선택 시 처리(object selection override)는 다음과 같이 Forge viewer에 이벤트 핸들러를 등록해 수행한다.
            ...
            viewer.addEventListener(Autodesk.Viewing.SELECTION_CHANGED_EVENT, onSelectionChanged);
        }

        function onSelectionChanged(event) {
            if (event.dbIdArray.length === 1) {
                viewer.getProperties(event.dbIdArray[0], function(data) {
                    console.log(data.name);
                    if(data.name.indexOf("Column") >= 0)
                    {
                        var instanceTree = viewer.model.getData().instanceTree;
                        var parentId = instanceTree.getNodeParentId(event.dbIdArray[0])
                        viewer.select([parentId]);
                    }
                });
            }
        }
        ...

HTML 요소 이벤트는 javascript event 규약을 참고해 처리한다. 

N-Screen 처리
N-Screen는 다양한 장치 스크린에 맞게 화면에 표시되는 정보양을 조정하는 것이다. 이는 javascript의 다음 변수와 resize 이벤트를 이용해 처리한다.
document.documentElement.clientWidth
document.documentElement.clientHeight
window.innerWidth
window.innerHeight

function calcWindowSize() {
   ...
}

window.addEventListener("resize", calcWindowSize);

결과는 다음과 같다.

외부 네트워크 DNS 서비스
현재는 앞서 개발된 모든 서버들이 내부 인터넷망에서만 동작될 것이다. 외부에 DNS 서비스하기 위해서는 다음 링크를 참고한다.

마무리
이제 BIM 공간 선택 시 해당 데이터를 차트 그래프와 연결하고, IoT 센서 장치를 각 공간에 설치하면, 현실과 디지털 공간과 연결한 소위 디지털 트윈이란 개념을 구현한 것이다. 개발해 본 결과를 보면, 기존 센서 데이터를 디지털 모델과 연계하거나 시뮬레이션했던 시스템들과 큰 차이가 없다. 이런 접근이 브랜드로 발전하고 대중화된 것이다. 디지털 트윈을 구현하는 방법은 다양할 수 있다. 구현 방법은 요구사항과 개발 전략에 따라 달라질 수 있다. 만약, GIS map을 사용한다면, Leaflet(리플릿), mapbox(맵박스), 3D Cesuim(세슘) 등 다양한 오픈소스 도구를 사용할 수 있다. 도면만 필요하다면, DWG/DXF 캐드 뷰어만 사용해도 된다. 데이터 모니터링만 필요하다면, 테이블 및 차트만 지원되는 데쉬보드 형태의 HCI(Human Communication Interface)도 충분하다.
오픈소스 기반 GIS 공간정보 뷰어 예시 

굳이, 3D 모델이 필요없이 효율적인 업무가 가능하다면, 디지털 트윈 타이틀이 붙은 과제라도 3D 모델 뷰어를 구현할 필요가 없다. 앞서 개발하는 과정을 보면 알겠지만, 디지털 트윈 개념을 서비스하기 위해서는 많은 비용과 노력이 필요하다. DATABASE, REST API, 보안, VIEWER, DASHBOARD, BUSINESS LOGIC, IoT, 서버, 데이터 모델링 등 욕심을 내다 보면 끝도 없다. 

보통, 사용자는 유지보수 비용을 크게 간과하는 경향이 있다. 실제 서비스 중인 소프트웨어는 유지보수 비용이 80~90%를 상회한다.

소프트웨어 공학 관점에서 보았을 때 불필요한 모델, 데이터의 구현은 유저의 시스템 사용을 매우 불편하게 할 뿐아니라 유지보수, 성능에 나쁜 영향을 준다. 이 시점에서 모든 것을 막연한 짐작으로 디지털 트윈이나 BIM으로 구현하면 편리하다는 주장은 근거가 매우 빈약하며, 쉽게 납득하기도 어렵다(발주자가 진정 돈을 아끼고 싶다면, 화려하고 복잡한 PPT는 조심해야 한다). 물리적 데이터를 디지털 공간에 맵핑하는 것이 문제라면, 그 요구사항과 구현이 유저 입장인지가 중요하다.

From UNF. 2/23/2021

레퍼런스
추신 - 이 정도까지 진행하는 데 Full M/M로 대략 1개월(Not including holiday) 걸린 것 같다. 이 정도로도 물리적 공간에 붙착된 데이터를 가상으로 모니터링하고 분석용 데이터를 획득할 수 있다. 이제 좀 더 편리한 인터페이스 구현을 위해, 다음과 같은 부분을 처리한다. 
  • BIM 공간과 그래프 연결. 이벤트 처리
  • IoT 센서 장치를 인터넷에 연결
  • IoT 센서 장치를 각 공간에 설치
  • 모니터링된 데이터에서 이상 패턴 감지를 위한 딥러닝 학습 모듈 개발
여기서, 다른 일을 하며 진행하므로, 개발 효율이 30~50% 정도... 그럼 앞으로 약 두달 정도해야 정리될 듯하다. 시간이 빡빡하기는 하지만, 센서 데이터와 글은 병행해 정리하기로 한다. - 2/25

추신 - 오토데스크 포지 제공 API가 원하는 모든 기능을 제공하지는 않는다(직접 해봐야 안다). 제공되지 않는 기능은 직접 구현해야 하며, 가끔은 DOM 해킹, 3차원 기하 계산 등을 코딩해야 할 수 있다. 참고로, 이런 노가다 코딩이 인공지능으로 자동화될 수 있다고 착각하지 말자(한참 멀었다). 

추신 - UNF에서 DT 웹서버 설정하고 실행함. 이제 자잘한 기술 개발 및 네트워크 설정만 남은 듯. - 3/10

추신 - ELO Twilight 들으며 작정하고 편안하게 코딩 중. 지금까지 너무나 주변 요구, 환경과 시선에 신경을 쓰고 살았음을 느낌. 아무도 터치하지 않으니 생각 정리되어 편하고, 일 잘 됨. 의미없는 생명력이라고는 1도 없는 공허한 수많은 R&D과제 무생물 포장들 신경 쓸 필요 없이, 내가 생각한 개념 데로 연구개발하니 속편하고 무언가 채워지는 느낌(인생이 아깝지 않다). 불과 두달전까지 잡다한 일에 치여 코딩은 커녕, 과제 업체 관리 보고 논문 특허 증빙자료 정량지표 채우기도 바빳던, 막연히 의미있을 거란 위안을 갖고 있을 때와는 너무 차이 남(안보고 안해도 되는 건데 술쳐먹고 노는 체질 아니니 스스로 힘들게 하는 거지). 예전에 Universe 를 만들겠다고 상상하며 개발했던 적이 있었음. 그땐 누가 시키지 않아도, 밤새고 프로그래밍한 후 실행되는 어플리케이션이 애완동물처럼 좋았던 때가 있었다. 

프로그래밍은 여행전 시동 걸어 출발한 후, 오랫동안 고통의 계곡을 넘은 후, 주변의 아름다운 경치가 한 눈에 들어오는 어드밴처와 비슷하다. 오래간에 그 느낌 맛보네. - 3/14

추신 - 드디어, 개발 끝남. 개발 기간은 1.5M/M. BIM 모델 연계, 차트 개발, 테이블 이벤트 처리, 센서 오작동 문제 처리, 웹 화면 스타일 처리, IoT 데이터 동적 업데이트 등 자잘한 것 개발 완료. 아직 완전히 마음에 드는 것은 아니지만, 대학교 내부에서 서비스 및 연구 분석 데이터 얻을 정도는 됨. 성능 최적화, 코드 리팩토링은 시간 나면 하기로. 이제 스캔, 딥러닝, 비전 작업을 시작. 다음주에는 이 내용을 글로 정리해야겠음. 이젠 여기 생활이 일상이 되어 간다. 점점 가족중심이 되어가는 듯... - 4/2

추신 - 측정해 보니, 각 공간별 온습도차이, 조도차이를 알 수 있음. 더불어, 조도차이로 사람 거주 유무도 예측 가능. 몇몇 센서는 서로 간섭되는 현상도 발견해 수정함. - 6/12/2021

추신 - 24/365 서버 실행 테스트 중. 5월부터 지금까지 운영해 모아보니, 전원이 공급되는 한 서버가 다운되거나 센서 동작 코드가 에러나는 경우는 없었음. - 8/10/2021

추신 - 데이터 취득 중 특정 센서 취득 시 무결성 문제 발견. 지금까지 모아둔 1기가 바이트 이상 데이터베이스 모두 리셋하고 코드 수정해 재취득 시작함 - 9/20/2021

추신 - 얼마전 부터 여기 학생, 다른 여러 교수들과 협업 중. 개발 기술을 브랜드화하기 위해, IoT 센서 패키징, 디자인 등을 브레인스토밍하였고, 그때 나온 의견을 바탕으로 작업을 진행하고 있다. 아울러, 데이터 무결성 등을 오랫동안 실행해보며 확인했고 몇몇 문제점을 수정하였다. 무결성이 확보되었으니, 딥러닝 학습용 데이터 다운로드받아 이상패턴 검출할 모델 만들면 될 듯. - 10/20/2021

2021년 2월 16일 화요일

Arduino nano BLE, Node-red기반 IoT 데이터 MongoDB 저장 및 모니터링하기

이 글은 Arduino nano BLE, Node-red기반 IoT 데이터 MongoDB 저장 및 모니터링하는 방법을 간략히 공유한다. 이 글에서 개발되는 시스템은 nano의 모든 센서 데이터를 획득해, node-red로 전달하고, node-red에서 mongodb로 저장한다. 이 글을 따라하면 다음과 같은 결과를 얻을 수 있다.

아두이노 나노 보드 사용 방법은 아래 링크를 참고한다.
mongodb에 저장되면, 이를 통해, 데쉬보드, 오토데스크 포지 뷰어 등에서 모니터링할 수 있을 것이다.

아키텍처 디자인
아키텍처는 간단히 다음과 같이 디자인한다. Forge와 DT app은 이전 블로그에서 개발한 오토데스크 포지 기반 앱을 사용한다.

아두이노 nano sense 코딩
다음과 같이 코딩하고 업로드한다. 업로드가 제대로 되지 않으면, 코드 에러, 나노 보드 회로 연결이 제대로 된 것인지, 연결된 부품이 제대로 동작되는 것인지 확인한다. SSD1306 OLED 및 센서 라이브러리 설치 관련 내용은 이전 블로그 글을 참고한다.
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Arduino_LSM9DS1.h>
#include <Arduino_HTS221.h>
#include <Arduino_LPS22HB.h>

#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 32 // OLED display height, in pixels

// Declaration for SSD1306 display connected using software SPI (default case):
#define OLED_RESET  -1
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

void setup() {
  Serial.begin(9600);
  Serial.println("Sensing");

  if (!IMU.begin()) {
    Serial.println("Failed to initialize IMU!");
    while (1);
  }
  Serial.print("Gyroscope sample rate = ");
  Serial.print(IMU.gyroscopeSampleRate());
  Serial.println(" Hz");
  Serial.println();
  Serial.println("Gyroscope in degrees/second");
  Serial.println("X\tY\tZ");  

  // SSD1306_SWITCHCAPVCC = generate display voltage from 3.3V internally
  if(!display.begin(SSD1306_SWITCHCAPVCC)) {
    Serial.println(F("SSD1306 allocation failed"));
    for(;;); // Don't proceed, loop forever
  }

  // Show initial display buffer contents on the screen --
  // the library initializes this with an Adafruit splash screen.
  display.display();
  delay(1000); 

  // Clear the buffer
  display.clearDisplay();
  display.setTextSize(1); // Draw 2X-scale text
  display.setTextColor(SSD1306_WHITE);
  // display.setFont(ArialMT_Plain_10);

  if (!HTS.begin()) {
    Serial.println("Failed to initialize humidity temperature sensor!");
    while (1);
  }
  
  if (!BARO.begin()) {
    Serial.println("Failed to initialize pressure sensor!");
    while (1);
  }
  testdrawline();      // Draw many lines
}

void loop() {
  sensingNano();    
}

void testdrawline() {
  int16_t i;
  display.clearDisplay(); // Clear display buffer

  for(i=0; i<display.width(); i+=4) {
    display.drawLine(0, 0, i, display.height()-1, SSD1306_WHITE);
    display.display(); // Update screen with each newly-drawn line
    delay(1);
  }
  for(i=0; i<display.height(); i+=4) {
    display.drawLine(0, 0, display.width()-1, i, SSD1306_WHITE);
    display.display();
    delay(1);
  }
  delay(250);
}

void sensingNano(void) {
  display.clearDisplay();

  float temperature = HTS.readTemperature();
  float humidity    = HTS.readHumidity();
  float pressure = BARO.readPressure();

  // print each of the sensor values
  Serial.print(temperature);
  Serial.print(", ");
  Serial.print(humidity);
  Serial.print(", ");
  Serial.print(pressure);
  
  display.setCursor(0, 24);
  String data = String("THP: ") + String(temperature, 1) + ", " + String(humidity, 1) + ", " + String(pressure, 1);
  display.println(data);


  float x, y, z;
  if (IMU.gyroscopeAvailable()) {
    IMU.readGyroscope(x, y, z);

    Serial.print(", ");
    Serial.print(x);
    Serial.print(", ");
    Serial.print(y);
    Serial.print(", ");
    Serial.print(z);

    display.setCursor(0, 0);
    String data = String("G: ") + String(x, 1) + ", " + String(y, 1) + ", " + String(z, 1);
    display.println(data);
  }

  if (IMU.accelerationAvailable()) {
    IMU.readAcceleration(x, y, z);

    Serial.print(", ");
    Serial.print(x);
    Serial.print(", ");
    Serial.print(y);
    Serial.print(", ");
    Serial.print(z);

    display.setCursor(0, 8);
    String data = String("A: ") + String(x, 1) + ", " + String(y, 1) + ", " + String(z, 1);
    display.println(data);    
  }

  if (IMU.magneticFieldAvailable()) {
    IMU.readMagneticField(x, y, z);

    Serial.print(", ");
    Serial.print(x);
    Serial.print(", ");
    Serial.print(y);
    Serial.print(", ");
    Serial.print(z);

    display.setCursor(0, 16);
    String data = String("M: ") + String(x, 1) + ", " + String(y, 1) + ", " + String(z, 1);
    display.println(data);       
  }

  Serial.println(" ");

  display.display();      // Show initial text
  delay(500);
}

시리얼 모니터에서 센서 데이터들이 제대로 출력되면 성공한 것이다.

mongo DB 설정
mongodb를 설치한다. 몽고디비 데이터 저장 폴더를 만들고, 경로를 다음과 같이 지정한다.
mkdir c:\data
cd data
mkdir db
export MONGO_PATH=/usr/local/mongodb
export PATH=$PATH:$MONGO_PATH/bin

node-red 를 실행한다. 노드래드의 팔레트 관리자 메뉴에서 node-red-node-mongodb2 패키지를 설치한다. 
npm install node-red-node-mongodb

몽고디비를 실행한다.
mongod

몽고디비 실행후 MongoDB compass 프로그램에서 sensor 데이터베이스와 data 컬랙션을 생성하거나, 다음과 같이 몽고디비 콘솔창을 실행해 명령을 입력한다(mongo).
use sensor
db.data.insert({sensor: "temp", date: "2021-2.19", value: "20.9"})

node-red와 센서 연결
node-red를 다음과 같이 디자인한다. 시리얼 노드를 더블클릭해, 연결된 아두이노 나노 보드의 COM 포트를 입력한다.
serial 노드를 생성해, function 노드와 연결한다. 그리고, function 노드를 더블클릭해 다음과 같이 코딩한다. function 출력은 12개로 설정한다. 시리얼에서 문자열로 가져온 메시지 데이터는 콤마 형식으로 구분되어 있다. 이를 분리해 배열로 만들고, 실수값으로 변환해 메시지 리스트를 만든다. 이를 리턴한다.

var output = msg.payload.split(",");

var msgList = [];
output.forEach(function(v, index)
{
    var msgIoT = {payload: parseFloat(v)};
    msgList.push(msgIoT);
});

return msgList;


node-red와 MongoDB 연결
디버깅을 위해 msg.payload, text 노드를 생성해 연결한다. mongodb out 노드를 생성해, 함수와 연결한다.

다음과 같이 몽고디비에 저장할 데이터베이스, 유저, 암호 등 연결 정보를 설정한다.


배포를 하면, 다음과 같이 몽고디비에 저장되는 센서 데이터 값을 확인할 수 있다.
show dbs
use sensor
db.data.find()

아울러, 노드레드 데쉬보드에서 다음과 같이 센서값들을 확인할 수 있다.

센서 값에 타임스탬프를 추가하기 위해, 함수를 하나 더 추가하고, 처음 만든 함수의 첫번째 출력과 노드를 연결한다. 함수의 출력은 기존에 만든 몽고디비 노드 입력과 연결한다. 

그리고, 새로 만든 함수는 다음과 같이 코딩한다.
var d = new Date();
var date = d.getFullYear() + '-' + d.getMonth() + '-' 
        + d.getDate() + '-' + d.getHours() + ':' + d.getMinutes()
        + ':' + d.getSeconds();

var newMsg = {
                payload: {
                    'sensor': 'temp',
                    'date' : date,                    
                    'value': msg.payload
                }
            };

return newMsg;

노드레드 프로그램 배포 전에 다음과 같이 mongodb collection data를 모두 삭제한다. 
db.data.remove({})

이제 배포하면, 다음과 같이 몽고디비에 저장된 결과를 확인할 수 있다.

몽고디비에 센서 값들을 이런 방식으로 저장할 수 있다. 참고로, 지금까지 개발된 노드레드 프로그램 코드는 다음과 같다. 참고로, 노드레드 import 메뉴를 이용해, 코드를 로딩할 수 있다.
[{"id":"4e59f461.a56f0c","type":"tab","label":"플로우 1","disabled":false,"info":""},{"id":"63af438.5058bbc","type":"serial in","z":"4e59f461.a56f0c","name":"","serial":"af5253fc.09b97","x":150,"y":160,"wires":[["c62c93a8.af08d","d243c665.c31f28","71fe46c0.dfaa18"]]},{"id":"d243c665.c31f28","type":"debug","z":"4e59f461.a56f0c","name":"","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":330,"y":60,"wires":[]},{"id":"19e8218a.5b6f8e","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":2,"width":0,"height":0,"name":"s2","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":140,"wires":[]},{"id":"c62c93a8.af08d","type":"function","z":"4e59f461.a56f0c","name":"","func":"var output = msg.payload.split(\",\");\n\nvar msgList = [];\noutput.forEach(function(v, index)\n{\n    var msgIoT = {payload: parseFloat(v)};\n    msgList.push(msgIoT);\n});\n\nreturn msgList;","outputs":12,"noerr":0,"initialize":"","finalize":"","x":350,"y":300,"wires":[["1e8b1389.ebbb5c","eb4101fb.ecc5a"],["19e8218a.5b6f8e","42b4327e.a5e49c"],["fedf546e.b92f38","4156ebd0.2fada4"],["7f000ff8.72b0c"],["7b969ff5.17729"],["723708ed.d0fe68"],["cc30198b.56e498"],["a62c4830.afe078"],["22e28692.dae18a"],["758c4be3.100204"],["3804bcdf.9caf44"],["29d226c9.8c1c4a"]]},{"id":"1e8b1389.ebbb5c","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":1,"width":0,"height":0,"name":"s1","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":100,"wires":[]},{"id":"fedf546e.b92f38","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":3,"width":0,"height":0,"name":"s3","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":180,"wires":[]},{"id":"7f000ff8.72b0c","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":4,"width":0,"height":0,"name":"s4","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":220,"wires":[]},{"id":"7b969ff5.17729","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":5,"width":0,"height":0,"name":"s5","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":260,"wires":[]},{"id":"723708ed.d0fe68","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":6,"width":0,"height":0,"name":"s6","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":300,"wires":[]},{"id":"cc30198b.56e498","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":7,"width":0,"height":0,"name":"s7","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":340,"wires":[]},{"id":"a62c4830.afe078","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":8,"width":0,"height":0,"name":"s8","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":380,"wires":[]},{"id":"22e28692.dae18a","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":9,"width":0,"height":0,"name":"s9","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":420,"wires":[]},{"id":"758c4be3.100204","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":10,"width":0,"height":0,"name":"s10","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":460,"wires":[]},{"id":"3804bcdf.9caf44","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":11,"width":0,"height":0,"name":"s11","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":500,"wires":[]},{"id":"29d226c9.8c1c4a","type":"ui_text","z":"4e59f461.a56f0c","group":"2b0b67e5.8c6878","order":12,"width":0,"height":0,"name":"s12","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":590,"y":540,"wires":[]},{"id":"71fe46c0.dfaa18","type":"ui_text","z":"4e59f461.a56f0c","group":"c65726f5.12d638","order":1,"width":0,"height":0,"name":"","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":310,"y":100,"wires":[]},{"id":"eb4101fb.ecc5a","type":"function","z":"4e59f461.a56f0c","name":"","func":"var d = new Date();\nvar date = d.getFullYear() + '-' + d.getMonth() + '-' \n        + d.getDate() + '-' + d.getHours() + ':' + d.getMinutes()\n        + ':' + d.getSeconds();\n\nvar newMsg = {\n                payload: {\n                    'sensor': 'temp',\n                    'date' : date,                    \n                    'value': msg.payload\n                }\n            };\n\nreturn newMsg;","outputs":1,"noerr":0,"initialize":"","finalize":"","x":540,"y":40,"wires":[["5325fecc.91cdc","40d4c998.b69328"]]},{"id":"5325fecc.91cdc","type":"mongodb out","z":"4e59f461.a56f0c","mongodb":"129644e9.2c45fb","name":"","collection":"data","payonly":false,"upsert":false,"multi":false,"operation":"insert","x":750,"y":40,"wires":[]},{"id":"40d4c998.b69328","type":"ui_text","z":"4e59f461.a56f0c","group":"c65726f5.12d638","order":1,"width":0,"height":0,"name":"db","label":"text","format":"{{msg.payload}}","layout":"row-spread","x":730,"y":100,"wires":[]},{"id":"4156ebd0.2fada4","type":"ui_chart","z":"4e59f461.a56f0c","name":"","group":"c65726f5.12d638","order":4,"width":0,"height":0,"label":"chart","chartType":"line","legend":"false","xformat":"HH:mm:ss","interpolate":"linear","nodata":"","dot":false,"ymin":"","ymax":"","removeOlder":1,"removeOlderPoints":"","removeOlderUnit":"3600","cutout":0,"useOneColor":false,"useUTC":false,"colors":["#1f77b4","#aec7e8","#ff7f0e","#2ca02c","#98df8a","#d62728","#ff9896","#9467bd","#c5b0d5"],"outputs":1,"useDifferentColor":false,"x":750,"y":200,"wires":[[]]},{"id":"42b4327e.a5e49c","type":"ui_gauge","z":"4e59f461.a56f0c","name":"","group":"c65726f5.12d638","order":3,"width":0,"height":0,"gtype":"gage","title":"gauge","label":"units","format":"{{value}}","min":0,"max":10,"colors":["#00b500","#e6e600","#ca3838"],"seg1":"","seg2":"","x":740,"y":160,"wires":[]},{"id":"af5253fc.09b97","type":"serial-port","serialport":"COM5","serialbaud":"9600","databits":"8","parity":"none","stopbits":"1","waitfor":"","dtr":"none","rts":"none","cts":"none","dsr":"none","newline":"\\n","bin":"false","out":"char","addchar":"","responsetimeout":"10000"},{"id":"2b0b67e5.8c6878","type":"ui_group","name":"Group 1","tab":"ad2f4438.b053a8","order":1,"disp":true,"width":"6","collapse":false},{"id":"c65726f5.12d638","type":"ui_group","name":"Group 2","tab":"ad2f4438.b053a8","order":2,"disp":true,"width":6},{"id":"129644e9.2c45fb","type":"mongodb","hostname":"127.0.0.1","topology":"direct","connectOptions":"","port":"27017","db":"sensor","name":""},{"id":"ad2f4438.b053a8","type":"ui_tab","name":"Tab 1","icon":"dashboard","order":1}]

이제 저장된 데이터 값을 데쉬보드나 오토데스크 포지에 연결해 보여줄 수 있다.

부록: 전류 소모에 따른 센서 오동작 문제
나노에 많은 전류 소모 센서나 장치를 부착하면, 충분하지 못한 전력때문에 센서 데이터는 부적확한 데이터를 출력하는 경우가 있다. 이에 주의한다.