이 프로젝트는 2013년 아두이노 CC 포럼에서 David Crocker가 발표한 프로젝트의 연장선입니다. 당시 코드는 공개되었으나, 실제로 제작되어 작동하는 증거(사진 또는 영상)를 찾을 수 없어 직접 테스트해보기로 했습니다.
먼저 GitHub에 공개된 기본 코드로 기본 버전을 제작하여 장치의 기능을 확인한 후, 청각 신호(부저음)와 16x2 LCD 디스플레이에 탐지된 물체 종류(철/비철) 및 근접도를 막대 그래프로 표시하는 기능으로 코드를 업그레이드했습니다.
장치는 매우 단순하게 구성되며 몇 가지 부품만 필요합니다:
아두이노 나노 마이크로컨트롤러
연산 증폭기 (본 프로젝트에서는 LT1677 사용, TL081 또는 741 대체 가능)
몇 개의 저항과 커패시터
소형 트랜지스터와 스피커
LCD 디스플레이
스위치 3개
포텐쇼미터
배터리
탐색 코일
작동 원리: VLF 유도 밸런스 방식
이 장치는 VLF(Very Low Frequency) 유도 밸런스(Induction Balance) 탐지 기술을 사용하며, 두 개의 동일한 코일(송신 코일과 수신 코일)로 구성됩니다. 모든 유도 밸런스 탐지기에서 코일 밸런스는 매우 중요합니다.
포텐쇼미터는 신호의 90도 위상 차 성분(작은 성분)을 제로(Zero) 조정하는 데 사용됩니다. (동위상 성분은 일반적인 IB 탐지기 방식대로 두 코일의 상대적 위치를 조정하여 널링합니다.)
각 코일은 지름 11cm 보빈에 0.5mm² 에나멜 동선 64턴을 D자 형태로 감고, 테이프로 감싼 뒤 알루미늄 호일로 차폐하고 주석 도금 동선으로 묶었습니다. (차폐가 단락 턴처럼 작동하지 않도록 작은 간격을 남겨두었습니다.) 그리고 플라스틱 플레이트에 타이랩으로 고정했습니다.
공진 주파수 설정
온라인 계산기를 사용하거나 오실로스코프로 1차 코일-커패시터 회로의 병렬 공진 주파수를 측정합니다. 위 치수대로 제작했다면 정확히 7.64kHz가 나오므로 코드에 직접 값을 입력하면 됩니다. 다른 공진 주파수 값이 나온다면 코드의 다음 부분에서 적절히 조정해야 합니다:
#define TIMER1_TOP (249) // 주파수 미세 조정
성능 테스트 결과
영상에서 보시듯 결과가 놀랍게 좋습니다. 금속이 없을 때 장치는 완벽하게 안정적입니다. 탐지 범위도 꽤 넓어서, 지름 15cm 금속 뚜껑을 30cm 이상 거리에서 탐지합니다. 더 큰 금속 물체는 40-50cm 이상 거리에서도 탐지됩니다. 공중에서 작은 동전도 15cm 거리에서 탐지 가능합니다.
전원은 리튬 배터리 2개를 직렬 연결(7.4V)하여 아두이노 Vin 핀에 공급합니다. 소비 전류가 20mA를 넘지 않아 배터리 수명이 매우 깁니다. 영상에서 장치 제작 전 과정이 자세히 설명되어 있습니다.
이것은 예비 결과일 뿐입니다. 향후 Tx 코일 구동용 파워 MOSFET 트랜지스터를 추가하면 감도를 크게 향상시킬 수 있을 것으로 보이며, 이는 향후 영상에서 테스트하고 소개할 예정입니다.
코드
아두이노 코드
// 유도 밸런스 금속 탐지기
// CPU 16MHz, ADC 클럭 1MHz로 동작. 이 속도에서 ADC 해상도는 8비트로 감소.
// 타이머 1: 시스템 클럭을 약 256으로 나누어 62.5kHz 사각파 생성
// 타이머 0: 타이머 1 출력을 8로 나누어 7.8125kHz 송신 코일 구동 신호 생성
// ADC: 코일 구동 전압 주기당 8개 샘플링, 45도 간격 4개 위상 검출기 구현
// 타이머 2: 이어폰/헤드셋용 톤 생성
#include <LiquidCrystal.h>
#include <LcdBarGraph.h>
#define max_ampAverage 200
LiquidCrystal lcd(6, 7, 10, 11, 12, 13);
LcdBarGraph lbg(&lcd, 16, 0, 1);
#define TIMER1_TOP (259) // 코일 튜닝을 위해 주파수 미세 조정 가능
#define USE_3V3_AREF (1) // USB 전원 사용 시 1, 임베디드 ATmega328P 사용 시 0
// 디지털 핀 정의
const int debugTxPin = 1;
const int encoderButtonPin = 2;
const int earpiecePin = 3;
const int T0InputPin = 4;
const int coilDrivePin = 5;
const int LcdRsPin = 6;
const int LcdEnPin = 7;
const int LcdPowerPin = 8;
const int T0OutputPin = 9;
const int lcdD4Pin = 10;
const int lcdD5Pin = 11;
const int LcdD6Pin = 12;
const int LcdD7Pin = 13;
// 아날로그 핀 정의
const int receiverInputPin = 0;
const int encoderAPin = A1;
const int encoderBpin = A2;
// ISR에서만 사용하는 변수
int16_t bins[4];
uint16_t numSamples = 0;
const uint16_t numSamplesToAverage = 1024;
// ISR과 외부에서 공유하는 변수
volatile int16_t averages[4];
volatile uint32_t ticks = 0;
volatile bool sampleReady = false;
// 외부에서만 사용하는 변수
int16_t calib[4];
volatile uint8_t lastctr;
volatile uint16_t misses = 0;
const double halfRoot2 = sqrt(0.5);
const double quarterPi = 3.1415927/4.0;
const double radiansToDegrees = 180.0/3.1415927;
const float phaseAdjust = (45.0 * 32.0)/(float)(TIMER1_TOP + 1);
float threshold = 5.0; // 낮을수록 높은 감도. 코일 밸런스가 잘 맞으면 10도 사용 가능.
void setup() {
lcd.begin(16, 2);
pinMode(encoderButtonPin, INPUT_PULLUP);
digitalWrite(T0OutputPin, LOW);
pinMode(T0OutputPin, OUTPUT);
digitalWrite(coilDrivePin, LOW);
pinMode(coilDrivePin, OUTPUT);
cli();
TCCR0B = 0; TIMSK0 = 0; TIFR0 = 0x07;
#if USE_3V3_AREF
ADMUX = (1 << ADLAR);
#else
ADMUX = (1 << REFS0) | (1 << ADLAR);
#endif
ADCSRB = (1 << ADTS2) | (1 << ADTS1);
ADCSRA = (1 << ADEN) | (1 << ADSC) | (1 << ADATE) | (1 << ADPS2);
DIDR0 = 1;
// 타이머 1 설정
TCCR1A = (1 << COM1A1) | (1 << WGM11);
TCCR1B = (1 << WGM12) | (1 << WGM13) | (1 << CS10);
TCCR1C = 0;
OCR1AH = (TIMER1_TOP/2 >> 8); OCR1AL = (TIMER1_TOP/2 & 0xFF);
ICR1H = (TIMER1_TOP >> 8); ICR1L = (TIMER1_TOP & 0xFF);
TCNT1H = 0; TCNT1L = 0;
TIFR1 = 0x07;
TIMSK1 = (1 << TOIE1);
// 타이머 0 설정
TCCR0A = (1 << COM0B1) | (1 << WGM01) | (1 << WGM00);
TCCR0B = (1 << CS00) | (1 << CS01) | (1 << CS02) | (1 << WGM02);
OCR0A = 7; OCR0B = 3; TCNT0 = 0;
sei();
while (!sampleReady) {}
misses = 0;
sampleReady = false;
Serial.begin(19200);
}
ISR(TIMER1_OVF_vect) {
++ticks;
uint8_t ctr = TCNT0;
int16_t val = (int16_t)(uint16_t)ADCH;
if (ctr != ((lastctr + 1) & 7)) { ++misses; }
lastctr = ctr;
int16_t *p = &bins[ctr & 3];
if (ctr < 4) {
*p += val;
if (*p > 15000) *p = 15000;
} else {
*p -= val;
if (*p < -15000) *p = -15000;
}
if (ctr == 7) {
++numSamples;
if (numSamples == numSamplesToAverage) {
numSamples = 0;
if (!sampleReady) {
memcpy((void*)averages, bins, sizeof(averages));
sampleReady = true;
}
memset(bins, 0, sizeof(bins));
}
}
}
void loop() {
while (!sampleReady) {}
uint32_t oldTicks = ticks;
if (digitalRead(encoderButtonPin) == LOW) {
// 캘리브레이션 버튼 눌림
for (int i = 0; i < 4; ++i) { calib[i] = averages[i]; }
sampleReady = false;
Serial.print("Calibrated: ");
lcd.setCursor(0,0); lcd.print("Calibrating... ");
for (int i = 0; i < 4; ++i) {
Serial.write(' '); Serial.print(calib[i]);
lcd.setCursor(0,1); lcd.print(' '); lcd.print(calib[4]); lcd.print(" ");
}
Serial.println();
} else {
for (int i = 0; i < 4; ++i) { averages[i] -= calib[i]; }
const double f = 200.0;
double bin0 = (averages[0] + halfRoot2 * (averages[1] - averages[3]))/f;
double bin1 = (averages[1] + halfRoot2 * (averages[0] + averages[2]))/f;
double bin2 = (averages[2] + halfRoot2 * (averages[1] + averages[3]))/f;
double bin3 = (averages[3] + halfRoot2 * (averages[2] - averages[0]))/f;
sampleReady = false;
double amp1 = sqrt((bin0 * bin0) + (bin2 * bin2));
double amp2 = sqrt((bin1 * bin1) + (bin3 * bin3));
double ampAverage = (amp1 + amp2)/2.0;
double phase1 = atan2(bin0, bin2) * radiansToDegrees + 45.0;
double phase2 = atan2(bin1, bin3) * radiansToDegrees;
if (phase1 > phase2) { double temp = phase1; phase1 = phase2; phase2 = temp; }
double phaseAverage = ((phase1 + phase2)/2.0) - phaseAdjust;
if (phase2 - phaseAverage > 180.0) {
if (phaseAverage < 0.0) phaseAverage += 180.0;
else phaseAverage -= 180.0;
}
// 진단용 출력
Serial.print(misses); Serial.write(' ');
if (bin0 >= 0.0) Serial.write(' '); Serial.print(bin0, 2); Serial.write(' ');
if (bin1 >= 0.0) Serial.write(' '); Serial.print(bin1, 2); Serial.write(' ');
if (bin2 >= 0.0) Serial.write(' '); Serial.print(bin2, 2); Serial.write(' ');
if (bin3 >= 0.0) Serial.write(' '); Serial.print(bin3, 2); Serial.print(" ");
Serial.print(amp1, 2); Serial.write(' '); Serial.print(amp2, 2); Serial.write(' ');
if (phase1 >= 0.0) Serial.write(' '); Serial.print(phase1, 2); Serial.write(' ');
if (phase2 >= 0.0) Serial.write(' '); Serial.print(phase2, 2); Serial.print(" ");
if (ampAverage >= 0.0) Serial.write(' '); Serial.print(ampAverage, 1); Serial.write(' ');
lcd.setCursor(0,0); lcd.print(" "); lcd.print(ampAverage);
lcd.setCursor(0,1); lbg.drawValue(ampAverage, max_ampAverage);
if (phaseAverage >= 0.0) Serial.write(' '); Serial.print((int)phaseAverage);
// 탐지된 물체 판별 및 알림
if (ampAverage >= threshold) {
// 코일 중심선에 수직으로 들었을 때:
// - 비철 금속: 음의 위상 이동 (두꺼운 구리/알루미늄 -90도, 얇은 알루미늄 -30도)
// - 철 금속: 0도 또는 작은 양의 위상 이동
// 위상 이동이 -20도 이하면 비철로 판정
if (phaseAverage < -20.0) {
Serial.print(" Non-ferrous");
lcd.setCursor(0,0); lcd.print("NonFerous ");
} else {
Serial.print(" Ferrous");
lcd.setCursor(0,0); lcd.print("Ferrous ");
}
// 신호 강도에 비례하는 톤 생성
float temp = ampAverage;
int thisPitch = map(temp, 10, 200, 100, 1500);
tone(3, thisPitch, 120);
while (temp > threshold) { Serial.write('!'); temp -= (threshold/2); }
}
Serial.println();
}
while (ticks - oldTicks < 8000) {}
}