データサイエンス入門 第14回 お天気ガジェットをGemini3で作る
データサイエンス入門
第14回 お天気ガジェットをGemini3の力を借りて作ってみる
◎はじめに
Youtubeなんかを見ていると、机の上のちょこっと置いておけるガジェット(タイヤやキャタピラで動くのもありますね)をラズベリーパイやESP32やArduinoあたりで作るのが流行っているように思います。AIを搭載するのは難易度が高そうですが、AIの力を借りて作ってみるのはいかがでしょうか。
今回は前から欲しかったウエザーステーション=お天気ガジェットを作ります。
ラズベリーパイをベースにして、机の上に置いて気温や気圧を表示するガジェットを最近随分と賢くなってきた気がするGoogleのGemini3の助けを大幅に借りて制作します。
◎必要なもの
ラズベリーパイ3B+ 以上
ラズパイOS:レガシー32ビット版
◎ウェザーステーションの制作
制作方針はGemini3に作って欲しいことをお願いする、です。
〇 教えてGemini3! その1
ラズパイにlcdを接続して表情豊かな顔を表示したい、やりかたを教えて
■Gemini3の回答:
親切にもLCDの接続方法から教えてくれます。
〇教えてGemini3! その2
ネットから気温情報を取得して、不快や回的な状況をJavaScriptを使って顔の表情を表示して、と頼みました。
Pythonプログラムも作ってくれましたが、方法1のJavaScriptを使う方法が一番滑らかに動きます。ファイル名.html 形式で保存します。JavaScriptなので、Firefoxやchromeなどのブラウザ上で動かします。ラズベリーパイのブラウザでも動作しそうなので、好都合です。
■Gemini3の回答:
できたのが図5です。よくできていますが、表情のベースが困り顔なので、ちょっと調整してもらいました。
〇教えてGemini3! その3
ネットから気温情報を取得して、快適や不快な状況をJavaScriptを使って顔の表情を表示して、3秒に1回、表情を変えて欲しい、と頼みました。図6
■ Gemini3の回答:
シンプルで好みの顔になりました。
〇教えてGemini3! その4
動かすラズパイの小さな横型画面を想定した画面構成や下部に余裕があるので、気圧変化グラフの表示をリクエストします。
■ Gemini3の回答:
横型は画面への収まりがいいです。
気圧変化グラフは天気予想に大いに役に立ちます。
〇教えてGemini3! その5
ラズベリーパイへの組込方法を考えてもらいます。「ラズベリーパイ3B+の起動時に指定のファイルを読み込んでFirefoxをkioskモードで起動する方法を教えて」
ブラウザにFirefoxを選んだのは、ラズパイ標準ブラウザであるChromiumよりも動作が軽く感じたからです。ラズパイB3は今となっては性能が控えめなので、なるべく動作が軽くなる工夫をします。なお、ラズパイOSはレガシー32ビット版を選択しています。
実はブラウザには全画面表示となり、マウスなどの操作を受け付けなくなる展示に最適なkioskモードがあることもGemini3に教えてもらいました。
起動後の解除はCtrl+F4です。また全画面と通常画面切り替えはF11です。
■ Gemini3の回答:
起動ファイルの書き方もきっちり教えてくれます。完成したhtmlファイルをラズパイに送って動かすとリセットからちゃんとブラウザであるFireFoxが起動してウエザーステーションが動作を開始します。
◎完成!
ラズベリーパイ3B+に3.5インチLCDを取り付けて、完成したhtmlファイルを動かせば完成です。ウエザーステーションとして机の上のマスコットとして活躍しています。
Gemini3などのAIにお願いすれば、構想したものを簡単にかつ早く形にすることができます。上手に使って夢を形にしてみませんか。
◎htmlプログラム
完成したウエザーステーションhtmlプログラムを示します。tenki_gr.htmlなどのファイル名でラズパイに保存します。
from IPython.display import HTML
html_code = """
<!-- Chart.js 読み込み -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<div style="background-color: #1a1a1a; padding: 20px; border-radius: 12px; text-align: center; width: 720px; font-family: sans-serif; color: white; margin: 0 auto;">
<h3 style="margin-top: 0; color: #ccc;">ウエザーステーション </h3>
<!-- 都市選択 -->
<div style="margin-bottom: 15px;">
<label for="city-select">地域: </label>
<select id="city-select" style="padding: 6px; border-radius: 4px; background: #333; color: white; border: 1px solid #555;">
<option value="35.7640,137.9299">Miyada</option>
<option value="35.7640,137.9299">Komagane</option>
<option value="35.6895,139.6917">東京</option>
<option value="34.6937,135.5023">大阪</option>
<option value="43.0621,141.3544">札幌</option>
<option value="26.2124,127.6809">沖縄</option>
</select>
<button onclick="fetchWeather()" style="padding: 6px 12px; margin-left: 6px; border-radius: 4px; background: #00e5ff; border: none; cursor: pointer; font-weight: bold; color: #000;">天気更新</button>
</div>
<!-- メインコンテンツ:顔と情報パネル -->
<div style="display: flex; align-items: stretch; justify-content: center; gap: 15px; margin-bottom: 15px;">
<!-- SVG顔描画エリア(左側) -->
<svg id="face-svg" width="340" height="240" viewBox="0 0 380 240" style="background: #050811; border-radius: 8px; transition: background 0.5s; flex-shrink: 0;">
<!-- 左目 (縦楕円) -->
<g id="left-eye-group" transform="translate(120, 105)" style="transition: transform 0.2s;">
<ellipse id="left-eye" cx="0" cy="0" rx="28" ry="50" fill="#00e5ff" style="transition: all 0.2s;" />
</g>
<!-- 右目 (縦楕円) -->
<g id="right-eye-group" transform="translate(260, 105)" style="transition: transform 0.2s;">
<ellipse id="right-eye" cx="0" cy="0" rx="28" ry="50" fill="#00e5ff" style="transition: all 0.2s;" />
</g>
<!-- 口 (シンプルライン) -->
<path id="mouth" d="M 160 180 Q 190 200 220 180" stroke="#00e5ff" stroke-width="7" fill="none" stroke-linecap="round" style="transition: all 0.2s;" />
</svg>
<!-- 情報表示パネル(右側) -->
<div style="background: #2a2a2a; padding: 15px; border-radius: 8px; flex-grow: 1; text-align: left; display: flex; flex-direction: column; justify-content: center;">
<!-- 時間表示 -->
<div id="time-info" style="font-size: 2.0em; text-align: center; font-weight: bold; color: #ffffff; margin-bottom: 8px;">--:--:--</div>
<!-- 気温・最高/最低気温(改行付き) -->
<div id="temp-info" style="font-size: 1.8em; text-align: center; font-weight: bold; color: #00e5ff;">データ取得中...</div>
<!-- 湿度・気圧 -->
<div id="sub-weather-info" style="font-size: 0.95em; text-align: center; font-weight: bold; color: #80d8ff; margin-top: 6px;">湿度: - % / 気圧: - hPa</div>
<!-- 本日の天気予想 -->
<div id="weather-info" style="font-size: 0.95em; text-align: center; font-weight: bold; color: #ffeb3b; margin-top: 6px;">天気予想: -</div>
<!-- 快適度ステータス -->
<div id="status-info" style="font-size: 0.85em; text-align: center; margin-top: 8px; color: #aaa;">-</div>
<!-- 動作情報 -->
<div id="action-info" style="font-size: 0.8em; text-align: center; margin-top: 6px; color: #777;">動作: -</div>
</div>
</div>
<!-- 24時間気圧変化グラフエリア -->
<div style="background: #222; padding: 12px; border-radius: 8px;">
<div style="font-size: 0.9em; text-align: center;font-weight: bold; color: #00e5ff; margin-bottom: 8px; text-align: left;"> 24時間の気圧変化 (+75 hPa 補正済)</div>
<div style="height: 150px; position: relative;">
<canvas id="pressure-chart"></canvas>
</div>
</div>
</div>
<script>
const svg = document.getElementById('face-svg');
const leftEye = document.getElementById('left-eye');
const rightEye = document.getElementById('right-eye');
const leftEyeGroup = document.getElementById('left-eye-group');
const rightEyeGroup = document.getElementById('right-eye-group');
const mouth = document.getElementById('mouth');
const timeInfo = document.getElementById('time-info');
const tempInfo = document.getElementById('temp-info');
const subWeatherInfo = document.getElementById('sub-weather-info');
const weatherInfo = document.getElementById('weather-info');
const statusInfo = document.getElementById('status-info');
const actionInfo = document.getElementById('action-info');
let currentDI = 60;
let currentTemp = 22;
let currentHumidity = 50;
let pressureChart = null;
// WMO天気コード変換マップ
function getWeatherText(code) {
if (code === 0) return "快晴 ";
if (code === 1 || code === 2 || code === 3) return "晴れ時々曇り ";
if (code === 45 || code === 48) return "霧 ";
if (code >= 51 && code <= 55) return "小雨 ";
if (code >= 61 && code <= 65) return "雨 ";
if (code >= 71 && code <= 75) return "雪 ";
if (code >= 80 && code <= 82) return "にわか雨 ";
if (code >= 95) return "雷雨 ";
return "曇り ";
}
// 時計更新処理(秒単位)
function updateClock() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
timeInfo.innerText = `${hours}:${minutes}:${seconds}`;
}
// Weather API経由で気温・湿度・気圧・24時間推移・天気を取得
async function fetchWeather() {
const coords = document.getElementById('city-select').value.split(',');
tempInfo.innerText = "データ取得中...";
subWeatherInfo.innerText = "湿度: 取得中... / 気圧: 取得中...";
weatherInfo.innerText = "天気: 取得中...";
try {
// current, daily, hourly (surface_pressure) を取得
const response = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${coords[0]}&longitude=${coords[1]}¤t=temperature_2m,relative_humidity_2m,surface_pressure&hourly=surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto`);
const data = await response.json();
currentTemp = data.current.temperature_2m;
currentHumidity = data.current.relative_humidity_2m;
// 現在気圧補正: 現地気圧 (surface_pressure) + 75 hPa
const rawPressure = data.current.surface_pressure || 1013;
const adjustedPressure = rawPressure + 75;
const maxTemp = data.daily.temperature_2m_max[0];
const minTemp = data.daily.temperature_2m_min[0];
const weatherCode = data.daily.weathercode[0];
currentDI = 0.81 * currentTemp + 0.01 * currentHumidity * (0.99 * currentTemp - 14.3) + 46.3;
// 気温(改行あり)
tempInfo.innerHTML = `気温: ${currentTemp}℃<br><span style="font-size:0.85em; font-weight:normal; color:#ccc;">(最高: ${maxTemp}℃ / 最低: ${minTemp}℃)</span>`;
subWeatherInfo.innerText = `湿度: ${currentHumidity}% / 気圧: ${Math.round(adjustedPressure)} hPa`;
weatherInfo.innerText = `天気予想 : ${getWeatherText(weatherCode)}`;
updateBaseState();
// 24時間気圧グラフの更新 (現在時刻以降の24時間を抽出)
if (data.hourly && data.hourly.surface_pressure) {
const now = new Date();
const currentHour = now.getHours();
// 24時間分データ(時刻ラベルと +75hPa 補正値)
const labels = [];
const pressures = [];
for (let i = 0; i < 24; i++) {
const index = currentHour + i;
if (index < data.hourly.surface_pressure.length) {
const timeLabel = `${(currentHour + i) % 24}:00`;
const val = data.hourly.surface_pressure[index] + 75; // +75補正
labels.push(timeLabel);
pressures.push(Math.round(val));
}
}
renderPressureChart(labels, pressures);
}
} catch (error) {
tempInfo.innerText = "取得失敗";
subWeatherInfo.innerText = "湿度: -- % / 気圧: -- hPa";
weatherInfo.innerText = "天気: 取得失敗";
}
}
// Chart.jsによる24時間気圧グラフ描画
function renderPressureChart(labels, dataPoints) {
const ctx = document.getElementById('pressure-chart').getContext('2d');
if (pressureChart) {
pressureChart.destroy();
}
pressureChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '気圧 (hPa)',
data: dataPoints,
borderColor: '#00e5ff',
backgroundColor: 'rgba(0, 229, 255, 0.1)',
borderWidth: 2,
pointRadius: 2,
fill: true,
tension: 0.3
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
x: {
ticks: { color: '#888', font: { size: 10 } },
grid: { color: '#333' }
},
y: {
ticks: { color: '#888', font: { size: 10 } },
grid: { color: '#333' }
}
}
}
});
}
// 不快指数に基づく基本スタイルの切り替え
function updateBaseState() {
if (currentDI < 55) {
svg.style.backgroundColor = "#081226";
statusInfo.innerText = `肌寒い (不快指数: ${currentDI.toFixed(1)})`;
} else if (currentDI >= 55 && currentDI < 70) {
svg.style.backgroundColor = "#051f2d";
statusInfo.innerText = `快適 (不快指数: ${currentDI.toFixed(1)})`;
} else if (currentDI >= 70 && currentDI < 75) {
svg.style.backgroundColor = "#2d1a05";
statusInfo.innerText = `やや不快 (不快指数: ${currentDI.toFixed(1)})`;
} else if (currentDI >= 75 && currentDI < 80) {
svg.style.backgroundColor = "#3a0e05";
statusInfo.innerText = `不快 (不快指数: ${currentDI.toFixed(1)})`;
} else {
svg.style.backgroundColor = "#4d0000";
statusInfo.innerText = `非常に不快 (不快指数: ${currentDI.toFixed(1)})`;
}
}
// デフォルト状態にリセット
function resetFaceState() {
leftEye.setAttribute('ry', '50');
rightEye.setAttribute('ry', '50');
leftEye.setAttribute('transform', '');
rightEye.setAttribute('transform', '');
leftEyeGroup.setAttribute('transform', 'translate(120, 105)');
rightEyeGroup.setAttribute('transform', 'translate(260, 105)');
if (currentDI < 55) {
mouth.setAttribute('d', 'M 165 185 Q 190 170 215 185');
} else if (currentDI >= 55 && currentDI < 70) {
mouth.setAttribute('d', 'M 160 175 Q 190 200 220 175');
} else {
mouth.setAttribute('d', 'M 160 190 Q 190 175 220 190');
}
}
// 3秒ごとのランダムアクション
function triggerRandomAction() {
resetFaceState();
const action = Math.floor(Math.random() * 5);
if (action === 0) {
actionInfo.innerText = "動作: まばたき";
leftEye.setAttribute('ry', '2');
rightEye.setAttribute('ry', '2');
setTimeout(() => {
leftEye.setAttribute('ry', '50');
rightEye.setAttribute('ry', '50');
}, 180);
} else if (action === 1) {
actionInfo.innerText = "動作: 左を見る";
leftEyeGroup.setAttribute('transform', 'translate(100, 105)');
rightEyeGroup.setAttribute('transform', 'translate(240, 105)');
} else if (action === 2) {
actionInfo.innerText = "動作: 右を見る";
leftEyeGroup.setAttribute('transform', 'translate(140, 105)');
rightEyeGroup.setAttribute('transform', 'translate(280, 105)');
} else if (action === 3) {
actionInfo.innerText = "動作: ウィンク";
leftEye.setAttribute('ry', '2');
} else if (action === 4) {
actionInfo.innerText = "動作: ジト目";
leftEye.setAttribute('ry', '20');
rightEye.setAttribute('ry', '20');
leftEye.setAttribute('transform', 'rotate(10)');
rightEye.setAttribute('transform', 'rotate(-10)');
}
}
setInterval(triggerRandomAction, 3000);
updateClock();
setInterval(updateClock, 1000);
fetchWeather();
setInterval(fetchWeather, 600000);
</script>
"""
HTML(html_code)
投稿者の人気記事

-
taketea2018
さんが
前の日曜日の18:43
に
編集
をしました。
(メッセージ: 初版)
-
taketea2018
さんが
前の日曜日の19:39
に
編集
をしました。
ログインしてコメントを投稿する