切り抜き動画作成の勧めとYoutubeのコメントを可視化する話

この記事はCCS †裏† Advent Calendar 2021 15日目の記事です。

adventar.org

昨日はマナ板さんのメッシャーズの紹介記事となっております。

p-manaita.hatenablog.com

目次

はじめに

どうも老害㌠いかろちゃんです。

今年はVTuber関連の記事が多くありますね。 それだけみんなVTuberを見ているということなんでしょう。

皆さんに推しのVTuberはいますか?そして応援しているでしょうか? 応援の方法としてはわかりやすい配信を見る、コメントをする、スーパーチャットをするようなことからTwitterでリプをつける、いいねをする、RTをする、ファンアートを描くなどなどたくさんの方法があります(ちなみにこれはわたしが勝手に言ってることではなく、わたしの最推しでありVTuberである因幡はねるちゃんが言っていることです)。 そしてそのひとつに切り抜き動画制作があります。

切り抜き動画とはライブ配信の面白かった箇所などを切り抜いて、編集を加えた動画のことです。 切り抜き動画作成は推しのよいところをダイレクトに人に伝えることができます。 言葉で良さを伝えるより、いいから見て!とできるのでより説得力が増します。 ヲタクは好きなものを人に伝えるの大好きですよね。切り抜き動画を作りたくなってきませんか? そして切り抜き動画は一般に元配信と比べ大幅に短くなるためライトな層にとっても見やすく好都合です。 したがって、切り抜き動画は推しを人々に広めたいヲタク、いきなり生配信をリアルタイムで見るのは重いライトな層両方にとってメリットがある*1のです。

さてTwitterに切り抜き動画を投稿する際に問題となるのが時間制限です。 部分的な切り抜きなら時間内に収めるのはさほど大変ではありません。 しかし、1つの配信をまとめたような動画の場合は時間に収めるという作業が大変となってきます。 そこで客観的な指標に頼りたくなってきます。 そのような指標として考えられるのが時間あたりのコメント数(以下CPT;comment per time)でしょう。 CPTが高いところはコメントが多く、配信も盛り上がっていたと考えられます。 そこでCPTを可視化しまたその時のコメントを表示できるようなツールがあれば切り抜き動画制作において便利である*2と考えられます。

この記事では時間あたりのコメント数を可視化するツールを作ります。 そしてケーススタディとして長時間配信から本ツールを利用して見どころを抽出し実際に切り抜き動画を作成します。 切り抜き動画制作のすすめの記事でもある*3ので切り抜き動画を実際にどう作っているかについても記載します。 プログラミングの部分、切り抜き動画制作の部分どちらかのみに興味がある人はそこだけ読んでも通じるように書いているつもりです*4

可視化ツールの方針

処理としては大きくYoutubeからコメントを取得する、それを可視化するの2ステップがあります。 Youtubeからコメントを取得する部分については今回は切り抜き動画を作成することが目的ですので、動画のダウンロードと同時に行います。 これはYoutubeからの動画のダウンロードに利用しているyt-dlpというツールが標準機能で備えています。 ということで手間も増えるわけでもないのに実装する必要はありませんのでyt-dlpの機能に頼りましょう。 yt-dlp--write-subsオプションでコメントを改行区切りにしたJSONを取得することが可能です。 コメント部分の一例としては下記です。 様々なデータが含まれていますが重要な部分はtextvideoOffsetTimeMsecです。 前者はコメントの内容、後者は開始時点からの経過時間(m sec)です。

{
  "replayChatItemAction": {
    "actions": [
      {
        "addChatItemAction": {
          "item": {
            "liveChatTextMessageRenderer": {
              "message": {
                "runs": [
                  {
                    "text": ""
                  }
                ]
              },
              "authorName": {
                "simpleText": "いかろちゃん"
              },
              "authorPhoto": {
                "thumbnails": [
                  {
                    "url": "https://yt4.ggpht.com/ytc/AKedOLSiuIye7HiIJiRQjNa68IAJ-ejTewkXBEf2CBwung=s32-c-k-c0x00ffffff-no-rj",
                    "width": 32,
                    "height": 32
                  },
                  {
                    "url": "https://yt4.ggpht.com/ytc/AKedOLSiuIye7HiIJiRQjNa68IAJ-ejTewkXBEf2CBwung=s64-c-k-c0x00ffffff-no-rj",
                    "width": 64,
                    "height": 64
                  }
                ]
              },
              "contextMenuEndpoint": {
                "commandMetadata": {
                  "webCommandMetadata": {
                    "ignoreNavigation": true
                  }
                },
                "liveChatItemContextMenuEndpoint": {
                  "params": "Q2tjS1JRb2FRMDVNUkMxeU1rWm9VRkZEUm1Fd1ZISlJXV1JDWlUxSVNtY1NKME5MWldFM2RWOHRaMTlSUTBaa1QxWjNaMjlrYkZJd1NGOUJNVFl6TmpJeE1qSXhNalEyTUJvcEtpY0tHRlZETUU5M1l6TTJWVGxzVDNscE9VZDRPVWxqTFRSeFp4SUxabE0zTlMxWWQybFFabGtnQVNnQk1ob0tHRlZEZW01c2VFRklaMDFGWmtaMExYcDNZbkZJZUVOQ2R3JTNEJTNE"
                }
              },
              "id": "CkUKGkNOTEQtcjJGaFBRQ0ZhMFRyUVlkQmVNSEpnEidDS2VhN3VfLWdfUUNGZE9Wd2dvZGxSMEhfQTE2MzYyMTIyMTI0NjA%3D",
              "timestampUsec": "1636212213195265",
              "authorBadges": [
                {
                  "liveChatAuthorBadgeRenderer": {
                    "customThumbnail": {
                      "thumbnails": [
                        {
                          "url": "https://yt3.ggpht.com/c2UzPBdLkckxF_PjgLfPlsyNWeozx1pTMI6LdH5xL4JO3XozEAyBCEiJZESdeSBdVmzQd1AsEqw=s16-c-k"
                        },
                        {
                          "url": "https://yt3.ggpht.com/c2UzPBdLkckxF_PjgLfPlsyNWeozx1pTMI6LdH5xL4JO3XozEAyBCEiJZESdeSBdVmzQd1AsEqw=s32-c-k"
                        }
                      ]
                    },
                    "tooltip": "Member (1 year)",
                    "accessibility": {
                      "accessibilityData": {
                        "label": "Member (1 year)"
                      }
                    }
                  }
                }
              ],
              "authorExternalChannelId": "UCznlxAHgMEfFt-zwbqHxCBw",
              "contextMenuAccessibility": {
                "accessibilityData": {
                  "label": "Comment actions"
                }
              },
              "timestampText": {
                "simpleText": "1:21:37"
              }
            }
          },
          "clientId": "CKea7u_-g_QCFdOVwgodlR0H_A1636212212460"
        }
      }
    ],
    "videoOffsetTimeMsec": "4897127"
  }
}

あとはこのファイルを解析して図にするだけですね!簡単!

スクリプトをかいてCLIでポチポチするのも趣があってよい*5のですが、今回はブラウザで操作できれば便利だなと思いJavaScriptコンパイルできる言語(AltJS)を採用することにしました。 具体的な言語としてはElmを採用しました。 ElmはWebアプリケーションを書くことに特化したシンプルで堅牢、そしてなにより書いててストレスがなく楽しい非常に実用的な言語です。 Elmというと関数型言語ということで取り上げられることが多いですが、 実用のために関数型言語のアプローチを採用しているだけで実用性を犠牲にせず極力シンプルな言語となるように設計されています。 ちなみに関数型言語については下記の国大くんの記事でおもしろく解説されています。

note.com

具体的な実装内容にしては1. JSONを読み込む、2. グラフ化する、3. 選択した時間のコメント一覧を表示する機能を実装します。 JSONを読み込む、コメントを表示するのはElmの標準ライブラリですればよいでしょう。 グラフ化についてはelm-visualizationというライブラリがあります。

package.elm-lang.org

このライブラリはJavaScriptのD3.jsに触発されて開発されたそうです。 一通りのグラフ化に必要な機能、および便利な統計関連の機能があります。 SVGとしてグラフを吐き出してくれます。 なによりサンプルが豊富なのが嬉しい。

ということでなんとなく方針が決まったところで実装していきましょう。

実装

まずはModel考えていきます。 グラフ化する情報、実際の情報は色々ありますが結局のところそれらは生のJSONから導出できます。 導出できるものをもっていてもそれらに矛盾が生じる可能性があるのでModelには生のJSONを保持するrawJsonを持っておきましょう。 また、どの時間のコメントを表示するかを選択するためにその時間の情報が必要ですね。 ということでselectedTimeも持っておきます。

type alias Model =
    { rawJson : Maybe String
    , selectedTime : Int
    }

つぎにMsgとupdateです。 状態遷移としてはJSON選択画面、JSON読込中、JSON読み込み後、選択している時間変更がありそうなので それをそのままMsgとします。 updateもそのまま書きましょう。ちなみにJSONの読み込みにはfileというライブラリを利用しています。

type Msg
    = JsonRequested
    | JsonSelected File
    | JsonLoaded String
    | ChangeSelectTime String

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        JsonRequested ->
            ( model
            , Select.file [ "text/json" ] JsonSelected
            )

        JsonSelected file ->
            ( model
            , Task.perform JsonLoaded (File.toString file)
            )

        JsonLoaded content ->
            ( { model | rawJson = Just content }
            , Cmd.none
            )

        ChangeSelectTime time ->
            ( { model | selectedTime = String.toInt time |> Maybe.withDefault 0 }, Cmd.none )

さて生JSONから得られる情報はいつどんなコメントがあったかですので、これからCPTを導出したいです。 CPTを導出するcount関数*6を実装しましょう。 方針としてはエラトステネスの篩みたいな発想でしていきます。 リストからある時間のコメントをすべて抽出して数を計算する、抽出されなかったものは他の時間のコメントである、 という当たり前のことをそのまま実装すればよいです。

count : List a -> List ( a, Int )
count targetList =
    count_impl [] targetList

count_impl : List ( a, Int ) -> List a -> List ( a, Int )
count_impl progressList target =
    case target of
        [] ->
            progressList

        res :: _ ->
            count_impl (( res, List.length (List.filter (\n -> n == res) target) ) :: progressList) (List.filter (\n -> n /= res) target)

つぎにJSONのエラーにあたったらそれを取り除くremoveErrorを実装しましょう。 というのも最初の2つは動画のメタデータがはいっているJSON,以降も何かしらのエラーに遭遇するかもしれませんがそれらは集計には不要な情報だからです。

removeError : List (Result err a) -> List a
removeError data =
    List.foldl
        (\x result ->
            case x of
                Err _ ->
                    result

                Ok val ->
                    val :: result
        )
        []
        data

あとはviewの部分ですね。 これはelm-visualizationのサンプルを参考に実装します。 内容についても公式ドキュメント以上の解説は特にありませんので詳しくは書きません。 ということで最終的には下記のようになりました。

module Main exposing (..)

import Axis
import Browser
import Color
import File exposing (File)
import File.Select as Select
import Html exposing (Attribute, Html, button, div, option, p, select, table, td, text, th, tr)
import Html.Attributes exposing (style, value)
import Html.Events exposing (on, onClick)
import Json.Decode exposing (Decoder, at, decodeString, field, index, int, string)
import Maybe
import Path exposing (Path)
import Scale exposing (ContinuousScale)
import Shape
import Statistics
import Task
import TypedSvg exposing (defs, g, linearGradient, stop, svg, text_)
import TypedSvg.Attributes exposing (class, dy, fill, fontFamily, id, offset, stopColor, stroke, textAnchor, transform, viewBox, x1, x2, y1, y2)
import TypedSvg.Attributes.InPx exposing (fontSize, height, strokeWidth, width, x, y)
import TypedSvg.Core exposing (Svg, text)
import TypedSvg.Types exposing (AnchorAlignment(..), Paint(..), Transform(..), em, percent)



-- constants--


w : Float
w =
    900


h : Float
h =
    450


arrow : String
arrow =
    "M2.73484 7.26517C2.88128 7.41161 3.11872 7.41161 3.26517 7.26517L5.65165 4.87868C5.7981 4.73223 5.7981 4.4948 5.65165 4.34835C5.5052 4.2019 5.26777 4.2019 5.12132 4.34835L3 6.46967L0.87868 4.34835C0.732233 4.2019 0.494796 4.2019 0.34835 4.34835C0.201903 4.4948 0.201903 4.73223 0.34835 4.87868L2.73484 7.26517ZM2.625 1.639


padding : Float
padding =
    30



-- type --


type alias Comment =
    { time : Int
    , text : String
    }


type alias Comments =
    List Comment


type alias Point =
    ( Float, Float )


type alias TimeSeries =
    List Point


xScale : TimeSeries -> ContinuousScale Float
xScale timeSeries =
    Scale.linear ( 0, w - 2 * padding )
        ( 0
        , List.foldl
            (\( x, y ) z ->
                if z < x then
                    x

                else
                    z
            )
            0
            timeSeries
        )


yScale : TimeSeries -> ContinuousScale Float
yScale timeSeries =
    Scale.linear ( h - 2 * padding, 0 )
        ( 0
        , List.foldl
            (\( x, y ) z ->
                if z < y then
                    y

                else
                    z
            )
            0
            timeSeries
            + 50
        )


xAxis : TimeSeries -> Svg msg
xAxis timeSeries =
    Axis.bottom [ Axis.tickCount (List.length timeSeries // 100) ] (xScale timeSeries)


yAxis : TimeSeries -> Svg msg
yAxis timeSeries =
    Axis.left [ Axis.tickCount 5 ] (yScale timeSeries)


transformToLineData : TimeSeries -> Point -> Maybe Point
transformToLineData timeSeries ( x, y ) =
    Just ( Scale.convert (xScale timeSeries) x, Scale.convert (yScale timeSeries) y )


tranfromToAreaData : List ( Float, Float ) -> ( Float, Float ) -> Maybe ( ( Float, Float ), ( Float, Float ) )
tranfromToAreaData timeSeries ( x, y ) =
    Just
        ( ( Scale.convert (xScale timeSeries) x, Tuple.first (Scale.rangeExtent (yScale timeSeries)) )
        , ( Scale.convert (xScale timeSeries) x, Scale.convert (yScale timeSeries) y )
        )


line : TimeSeries -> Path
line timeSeries =
    List.map (transformToLineData timeSeries) timeSeries
        |> Shape.line Shape.monotoneInXCurve


area : TimeSeries -> Path
area timeSeries =
    List.map (tranfromToAreaData timeSeries) timeSeries
        |> Shape.area Shape.monotoneInXCurve

-- MAIN

main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }


-- MODEL


type alias Model =
    { rawJson : Maybe String
    , selectedTime : Int
    }


init : () -> ( Model, Cmd Msg )
init _ =
    ( Model Nothing 0, Cmd.none )


-- UPDATE


type Msg
    = JsonRequested
    | JsonSelected File
    | JsonLoaded String
    | ChangeSelectTime String


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        JsonRequested ->
            ( model
            , Select.file [ "text/json" ] JsonSelected
            )

        JsonSelected file ->
            ( model
            , Task.perform JsonLoaded (File.toString file)
            )

        JsonLoaded content ->
            ( { model | rawJson = Just content }
            , Cmd.none
            )

        ChangeSelectTime time ->
            ( { model | selectedTime = String.toInt time |> Maybe.withDefault 0 }, Cmd.none )


-- VIEW


count : List a -> List ( a, Int )
count targetList =
    count_impl [] targetList


count_impl : List ( a, Int ) -> List a -> List ( a, Int )
count_impl progressList target =
    case target of
        [] ->
            progressList

        res :: _ ->
            count_impl (( res, List.length (List.filter (\n -> n == res) target) ) :: progressList) (List.filter (\n -> n /= res) target)


videoOffsetTimeDecoder : Decoder Int
videoOffsetTimeDecoder =
    at [ "replayChatItemAction", "videoOffsetTimeMsec" ] string |> Json.Decode.map (\s -> String.toInt s |> Maybe.withDefault 0)


commentDecoder : Decoder String
commentDecoder =
    at [ "replayChatItemAction", "actions" ] <|
        index 0 <|
            at [ "addChatItemAction", "item", "liveChatTextMessageRenderer", "message", "runs" ] <|
                index 0 <|
                    field "text" string


removeError : List (Result err a) -> List a
removeError data =
    List.foldl
        (\x result ->
            case x of
                Err _ ->
                    result

                Ok val ->
                    val :: result
        )
        []
        data


view : Model -> Html Msg
view model =
    case model.rawJson of
        Nothing ->
            button [ onClick JsonRequested ] [ Html.text "Load JSON" ]

        Just content ->
            let
                resolution =
                    60000

                comments =
                    String.split "\n" content
                        |> List.map (decodeString <| Json.Decode.map2 Comment videoOffsetTimeDecoder commentDecoder)
                        |> removeError
                        |> List.map (\comment -> { comment | time = comment.time // resolution })
                        |> List.filter (\comment -> comment.time == model.selectedTime)
                        |> Debug.log "com:"

                compileList =
                    String.split "\n" content
                        |> List.map (decodeString videoOffsetTimeDecoder)
                        |> removeError
                        |> List.map (\x -> x // resolution)
                        |> count
                        |> List.map (\( x, y ) -> ( toFloat x, toFloat y ))
                        |> Debug.log "res:"
            in
            div [] [ view_graph compileList, viewSelectTime compileList, viewComment comments ]


viewComment : Comments -> Html Msg
viewComment comments =
    table [] <| tr [] [ td [] [ Html.text "comment" ] ] :: List.map (\comment -> tr [] [ td [] [ Html.text comment.text ] ]) comments


onChange : (String -> msg) -> Attribute msg
onChange handler =
    on "change" (Json.Decode.map handler Html.Events.targetValue)


viewSelectTime : TimeSeries -> Html Msg
viewSelectTime timeSeries =
    select [ onChange ChangeSelectTime ] <| List.map (\( time, _ ) -> option [ value <| String.fromFloat time ] [ Html.text <| String.fromFloat time ]) timeSeries


peaksView : TimeSeries -> List (Svg msg)
peaksView data =
    data
        |> Statistics.peaks Tuple.second { lookaround = 50, sensitivity = 1, coallesce = 25 }
        |> Debug.log "peeks:"
        |> List.map
            (\( x, y ) ->
                let
                    xpos =
                        Scale.convert (xScale data) x

                    anchor =
                        if xpos - padding < 50 then
                            AnchorStart

                        else if xpos + padding > w - 50 then
                            AnchorEnd

                        else
                            AnchorMiddle
                in
                g [ transform [ Translate (xpos + padding) (Scale.convert (yScale data) y - 12) ] ]
                    [ TypedSvg.path [ TypedSvg.Attributes.d arrow, fill (Paint Color.red) ] []
                    , TypedSvg.text_ [ fontSize 11, fontFamily [ "sans-serif" ], textAnchor anchor, TypedSvg.Attributes.InPx.y -5 ]
                        [ TypedSvg.Core.text <| String.fromFloat x ]
                    ]
            )


view_graph : TimeSeries -> Html msg
view_graph timeSeries =
    div []
        [ svg [ viewBox 0 0 w h ]
            [ g [ transform [ Translate (padding - 1) (h - padding) ] ]
                [ xAxis timeSeries ]
            , g [ transform [ Translate (padding - 1) padding ] ]
                [ yAxis timeSeries ]
            , g [ transform [ Translate padding padding ], class [ "series" ] ]
                [ Path.element (area timeSeries) [ strokeWidth 3, fill <| Paint <| Color.rgba 1 0 0 0.54 ]
                , Path.element (line timeSeries) [ stroke <| Paint <| Color.rgb 1 0 0, strokeWidth 1, fill PaintNone ]
                ]
            , g [] <| peaksView timeSeries
            ]

ケーススタディ

ケーススタディとして最適なのは長時間配信で実際に盛り上がっているところを抽出できるかでしょう。 今回はわたしの推しの一人であり、長時間配信に定評のある?白宮みみちゃんの配信から切り抜き動画を作ってみましょう。 今回対象とするのは下記のGetting Over It with Bennett Foddy、通称壺おじの耐久配信です。 10時間もあれば文句ないですね。

www.youtube.com

まずは対象の動画とコメントをダウンロードします。 オプションはmp4形式で最高画質となるようにダウンロードし、コメント等の他の情報もあわせてダウンロードするようにしています。

yt-dlp --write-subs -f "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best" --merge-output-format mp4 https://www.youtube.com/watch?v=1eepJJ2IdEI

これでダウンロードされたjsonがコメントファイル、mp4が動画本体となります。 ここで今回作ったツールを利用します。ダウンロードしたJSONを読み込んでみましょう。

f:id:ikaro1192:20211215155902p:plain

いくつかのピークが見られますが、今回は250分のところのピークのコメントを見てみます。

f:id:ikaro1192:20211215160101p:plain

「なんこれなん」がキーワードとしてありそうです。 ということで250分の1分前あたり*7から動画を見てみます。 どうやら落下してしまい唐突に自作の歌を歌い始めたようですね...かわいい... 切り抜くのは歌だけでもいいんですが、その前の声にならないうめき?やおそらくふとももペチペチしているところもかわいい... そしてこの落下のみみ虐とその後の空気感いいですね... なによりアホっぽい言動しておきながら、実は前日からの2日連続耐久配信でめげずに続けてるそのガッツがとても好き... ハッ...ヲタクが出てしまいました。内容に戻りましょう。 そこで今回はちょっと長めになってしまいます*8が落下してから歌い終わるまでを切り抜くことにしました。

切り抜く範囲が決まったところで動画編集ソフトに取り込みます。 わたしはPremiere Proを使っていますが何でもいいと思います。 あとは切り抜いて字幕つけて、必要に応じてエフェクトつけて完成ですね。 ちなみに編集途中は下記みたいな感じです。

f:id:ikaro1192:20211215161503p:plain

あとは書き出してTwitterに投稿するのみ。 この時TwitterのMediaStudioの機能を使うと便利です。 サムネイルを変えたり、動画をクリックすると元配信を開けるようにしたり(Call to action)予約投稿ができたりします。 とくにファンとして切り抜き動画を作る際は推しのチャンネル登録をしてもらって見てもらうというのが最重要になりますので、 Call to actionは確実に貼っています。 また同じファンの人(あるいは推し本人)が見つけて拡散してくれることもあるので、切り抜き動画タグがある場合は忘れずにつけましょう*9。気になった人が別の切り抜き動画を探す助けにもなります。

ということで投稿しました。

ちなみに切り抜き動画作成に関する情報は下記にまとめています。 わたしはMacを利用していますが、Windowsの情報も載っていますもし気になった人がいたらどうぞ。

scrapbox.io

評価

切り抜き動画作成にかけた時間はダウンロード時間を除くと約40分です。 そして当然ながらほぼ動画編集の時間です。

一応ツイートのインプレッション関連情報やアナリティクスを見てみましょう(投稿後約8時間)。 少なくとも0いいねRTではないこと、再生時間が一人あたり約0.5分であることからそれなりに見てもらえたんじゃないでしょうか?...

f:id:ikaro1192:20211215165228p:plainf:id:ikaro1192:20211215165231p:plain

追記:その後、15RT,30いいね、リンクのクリックが10超えました。 まずまずの成功ではないでしょうか。

考察・まとめ

今回は時間あたりのコメント数が見どころの客観的指標になるのではという仮説の元それを可視化するツールを作成しました。 今回はツールの作成にElmを利用しましたが、このようなものが作れる程度にはライブラリが豊富であり、 また普段フロントエンドを全く触らない筆者であっても扱える簡単さを備えていることがわかりました。

そしてツールを利用することによりたしかに面白いところの抽出に成功しました。 そのおかげで作業時間の殆どを動画編集に当てることができました。 ただまだ色々粗があるので改善して一般公開できるようにしたいですね...

ただツールによる抽出には功罪があると思っていて、 おそらく万人に刺さるものは抽出できますが少ない人に深く刺さるというのはやはり自分の目で見てよいと思ったところを切り抜くことだと思います。 こちらについては評価できていませんが、おそらくTwitterに投稿する場合は似たような価値観の人がFF関係にあると思いますので、 感性が近い人が多く自分の感性で切り抜きを作るのがいいんじゃないかと思います。

ということでどうしょうもないときはほどほどにツールの力を使いつつ、推しへの愛を持って切り抜きを作っていきましょう。

ちなみにわたしの作った切り抜きは下記のモーメントにまとまっています。 ぜひわたしの推したちをよろしくお願い致します!!!

https://twitter.com/i/moment_maker/preview/1442928180029898753 https://twitter.com/i/moment_maker/preview/1373265486046097408

次のキキくんの記事です。好きなことが並んでる記事はいいですね。

alice-dreamoon.hatenadiary.jp

*1:とはいえ人によって切り抜き動画に対する考え方が違ったり、切り抜き動画作成を許可しているものの作っていい切り抜きに制限がある場合がありますので各個人、各企業の切り抜き動画についての規約はしっかり読みましょう。

*2:といっても最終的には推しに対する愛がいちばん大事な要素だと思います。少なくともわたしはお金上げるからひろゆきの切り抜き動画を作ってと言われても作ろうとは思いません。

*3:本当はこちらがメインです。が、例年プログラミングに多少とも関わりある記事を書いているので今年も書きたい。ということで主題が2つある記事になってしまいました。

*4:この文章を書いた時点では少なくともそう思っていた

*5:ちなみにいままでは古き良きシェルでワンライナーガリガリやってました

*6:あとで命名が最悪だなと思った

*7:コメントを打つ速度、反映速度から実際の反応がよかったシーンとコメント投稿までにはラグがあります

*8:Twitterだとだいたい10秒毎に動画を最後まで視聴してくれる人の割合が半減していくのでできれば短いほうがいい

*9:とはいっても最近は動画とタグの混在はシャドウバン率があがっている気がする。難しい問題

Gitの内部をPythonで覗いてみた

この記事はCCS †裏† Advent Calendar 2020の20日目の記事です。

adventar.org

前日の記事はスルメちゃんのダイエット記事。楽してダイエットはできないんですね(知ってた)。

castleofkraken.hatenablog.com

わたしの記事ではPythonを使ってGitの中身を覗いてみようと思います。

目次

はじめに

どうも千葉大電子計算機研究会(以下CCS)、老害㌠いかろちゃんです。今回はCCSの人も使うことが多いであろう(?)Gitについて実装しながら解説します。

Gitはバージョン管理システム(VCS; Version Control System)の一つです。Gitを利用することでファイルの変更の歴史を自由に行き来すること、変更や分岐が可能になります。Gitの説明は巷に溢れていますのでここではこれ以上詳しく説明しません。

UNIXでのプログラミングの指標の一つとして挙げられる「Notes on Programming in C」では「プログラミングの中核はデータ構造であり、アルゴリズムではない。よいデータ構造を選び物事をうまく組み合わせれば、アルゴリズムはほとんど自明になる。」と述べています*1。Gitも例に漏れずスマートなデータ構造を採用しています。しかし、一般にGitの入門書では操作に注目されがちで、どうやってデータを管理しているかについてはあまり言及されません。今回はgitがどのようなデータ構造を採用し管理しているのかという視点でGitについて解説していきます。Gitの標準でもそういった低レベルなデータ構造について閲覧や操作を行うコマンドが提供されています。しかし、そういったコマンドを漫然と叩いても理解はしにくいでしょう。まずはGitの内部構造について軽く紹介します。次にGitがバージョン管理に使っている各種ファイルのパーサを実装することで理解を深めます。その後、実際にgitコマンドと作成したコマンドをあわせて使用してどのように各種ファイルが変化するかを見ていきましょう。また今回の大部分は下記のPro Gitのオンライン記事を参考に書かれています。記事内では深い解説はしておりませんのでより詳細に立ち入りたい場合はこちらを参照しつつ読むと良いでしょう。

git-scm.com

Gitの内部構造の概観

Gitの内部構造を一言で言えば状態を格納しているKey-Value Storeです。つまり、ある時点の状態毎にあるキーを割り当て、そのキーを用いてその状態を特定、取り出すことができるシステムです。状態(たとえばファイルの内容)に対してSHA1ハッシュを計算したものをキーとして利用することで、キーと状態を結びつけています(Gitを使ったことがある方ならハッシュを使って色々操作したことがありますよね)。従って、内容が同一ならファイル名やいつ作られたかが異なっていても1つのSHA1ハッシュが割り当てられます。また逆に内容が異なれば別のキーが割り当てられます*2

Gitのパーサを実装する

次はGitが利用しているファイルについて見ていきましょう。Gitは.git配下のファイルによって変更履歴を管理しています。とりあえずどのようなディレクトリ構成になっているか見てみましょう。

.git
├── COMMIT_EDITMSG
├── HEAD
├── config
├── description
├── hooks
│   ├── applypatch-msg.sample
│   ├── commit-msg.sample
│   ├── fsmonitor-watchman.sample
│   ├── post-update.sample
│   ├── pre-applypatch.sample
│   ├── pre-commit.sample
│   ├── pre-merge-commit.sample
│   ├── pre-push.sample
│   ├── pre-rebase.sample
│   ├── pre-receive.sample
│   ├── prepare-commit-msg.sample
│   └── update.sample
├── index
├── info
│   └── exclude
├── logs
│   ├── HEAD
│   └── refs
│       └── heads
│           └── master
├── objects
│   ├── 01
│   │   └── 0d60284bb2e1e442be64d53d135aaafd895386
│   ├── 1d
│   │   └── 17dae13b53adda563547053eb79233b236f797
│   ├── 1f
│   │   └── be2aa5acf4cfb2bda94cbecc2424da56902556
(中略)
│   ├── ff
│   │   └── 42aba00f6dbf5803d00737ecafa6561ec0c878
│   ├── info
│   └── pack
└── refs
    ├── heads
    │   └── master
    └── tags

重要なディレクトリ・ファイルは"objects", "refs"の2つのディレクトリ,そして"index"と".git/HEAD"という2つのファイルです。今回はこれらをパースするプログラムを書いていきます。言語は何でも良いのですが、今回はPythonを使って実装します。

.git/objects

まずは.git/objectsからいきましょう。先程のディレクトリ構造を見ればわかるようにSHA1ハッシュの先頭2桁をサブディレクトリに、その下に残り38桁をファイル名にしています。このファイルの中身を見ていく前にまずはキーの一覧を表示するプログラムを作りましょう。下記のようになります。

import os


def ls_obj():
    object_dirs = os.listdir(path='.git/objects')
    for object_dir in object_dirs:
        if len(object_dir) == 2:
            print(object_dir + (os.listdir(path='.git/objects/' + object_dir)[0]))

文章で書いたことをそのままプログラムに落とし込んだだけですね。

次は.git/objects配下のファイルの中身をいい感じに表示するプログラムを作りましょう。その前にこの.git/objectsに保存されているファイルについての概要を述べます。ここに保存されているファイルはGitオブジェクトです。Gitオブジェクトにはblob, tree, commitの3種類があります。blobはファイルの内容のみを格納しているオブジェクトですblobにはファイル名すら格納されていません。ほんとうに内容だけです。treeはblobと紐づくファイル名を格納し、さらに複数のファイルをまとめることができるオブジェクトです。treeは要素として複数のtreeあるいはblobオブジェクトを含みます。blobとtreeオブジェクトだけではある瞬間の状態を格納することはできても状態の履歴を追跡することができません。状態の履歴を追跡するためのオブジェクトがcommitオブジェクトです。commitオブジェクトは要素として対応するtreeと親コミット、そしてコミットコメント等の付加的情報を持ちます。

すべてのGitオブジェクトは「ヘッダ+\0*3+コンテンツ」がzip圧縮されたものとなっています*4。ヘッダ部分はオブジェクトの種類に関わらず共通で「オブジェクトタイプ コンテンツサイズ」とスペース区切りになっています。コンテンツ部分のフォーマットはオブジェクトの種類によって異なります。BlobとCommitはそのままで読めます。他方Treeは少し複雑で工夫が必要です。「モード ファイル名\0オブジェクトへの参照(SHA1ハッシュ)」が持っている参照の数だけ含まれています。

import zlib


class GitObject:
    @property
    def object_type(self) -> str:
        return "unknown"

    def print(self):
        print("not implemented")


class Blob(GitObject):

    def __init__(self, data: bytes):
        self.data = data.decode()
        pass

    def print(self):
        print(self.data)

    @property
    def object_type(self) -> str:
        return "blob"


class TreeElement:
    def __init__(self, mode: str, filename: str, sha1_hash: int):
        self.mode = mode
        self.filename = filename
        self.sha1_hash = sha1_hash


def get_tree_elements(data: bytes):
    if len(data) == 0:
        return []
    else:
        mode, tmp = data.split(b' ', 1)
        filename, tmp2 = tmp.split(b'\0', 1)
        sha1_hash = tmp2[:20]
        body = tmp2[20:]
        return [TreeElement(mode.decode(), filename.decode(), sha1_hash.hex())] + get_tree_elements(body)


class Tree(GitObject):

    def __init__(self, data: bytes):
        self.elements = get_tree_elements(data)

    @property
    def object_type(self) -> str:
        return "tree"

    def print(self):
        for element in self.elements:
            print("{} {} {}".format(element.mode, element.filename, element.sha1_hash))


class Commit(GitObject):

    def __init__(self, data):
        self.data = data.decode()
        pass

    def print(self):
        print(self.data)

    @property
    def object_type(self) -> str:
        return "commit"


# メタクラス使うともっとスマートにできるが...
def create_git_object(obj_type: str, data: bytes):
    if obj_type == "blob":
        return Blob(data)
    elif obj_type == "tree":
        return Tree(data)
    elif obj_type == "commit":
        return Commit(data)
    else:
        return GitObject()


def cat_obj(hash_val):
    with open('.git/objects/' + hash_val[:2] + "/" + hash_val[2:], 'rb') as gitobj:
        res = zlib.decompress(gitobj.read())
        print("hash:{}".format(hashlib.sha1(res).hexdigest()))

        header, body = res.split(b'\0', 1)
        obj_type, obj_size = header.decode().split()
        obj = create_git_object(obj_type, body)

        print("type:{}".format(obj.object_type))
        obj.print()

基本的には説明したことをそのままコードに落としただけです。特殊なのといえばget_tree_elements関数でしょうか?これは再帰を使って渡されたバイナリデータを"畳み込む"ことによってTreeの要素のリストを作っています。

.git/index

次は.git/indexを見ていきます。gitではcommit前にaddをします。その時に情報が書き込まれるのがこの.git/indexです。このファイルを元にcommit時にTreeオブジェクトとそれを指しているCommitオブジェクトが生成されます。.git/indexはバイナリファイルです。フォーマットは下記に書いてあるとおりです。 github.com

バイナリファイルである点が厄介ですが、フォーマットに従って読むだけなのでさくっと実装しましょう。 下記のようになります。

import zlib


def calc_padding(n: int) -> int:
    floor = (n - 2) // 8
    target = (floor + 1) * 8 + 2
    return target - n


def cat_index():
    with open('.git/index', 'rb') as index_file:
        index_file_data = index_file.read()
        header_sig = index_file_data[0:4]
        header_ver = index_file_data[4:8]
        header_index_number = int.from_bytes(index_file_data[8:12], byteorder='big')
        print("version:{}".format(int.from_bytes(header_ver, byteorder='big')))

        offset = 12

        print("---")

        for _ in range(header_index_number):
            ctime_s = index_file_data[offset:offset + 4]
            ctime_n = index_file_data[offset + 4:offset + 8]
            mtime_s = index_file_data[offset + 8:offset + 12]
            mtime_n = index_file_data[offset + 12:offset + 16]
            dev = index_file_data[offset + 16:offset + 20]
            inode = index_file_data[offset + 20:offset + 24]
            mode = int.from_bytes(index_file_data[offset + 24:offset + 28], byteorder='big')
            uid = int.from_bytes(index_file_data[offset + 28:offset + 32], byteorder='big')
            gid = index_file_data[offset + 32:offset + 36]
            file_size = int.from_bytes(index_file_data[offset + 36:offset + 40], byteorder='big')
            sha1 = index_file_data[offset + 40:offset + 60].hex()

            assume_valid_flag = int.from_bytes(index_file_data[offset + 60:offset + 62], byteorder='big') & 0x800
            extended_flag = int.from_bytes(index_file_data[offset + 60:offset + 62], byteorder='big') & 0x800
            stage = int.from_bytes(index_file_data[offset + 60:offset + 62], byteorder='big') & 0x300
            name_length = int.from_bytes(index_file_data[offset + 60:offset + 62], byteorder='big') & 0x0FFF

            name = index_file_data[offset + 62:offset + 62 + name_length].decode()

            padding = calc_padding(name_length)
            offset = offset + 62 + name_length + padding
            print("{} {} {}".format(oct(mode), name, sha1))

今回はとりあえず一部情報のみを表示するようにしてみました。フォーマット通りにパースしただけですが、わかりにくいのはcalc_padding関数でしょうか。これは「1-8 nul bytes as necessary to pad the entry to a multiple of eight bytes while keeping the name NUL-terminated.」という要件を満たすようにいれてあります。つまり各エントリを8 byteの倍数にしたいという要求ですね。途中で出てきている2が唐突ですが、これがファイル名が出てくる前までに62 byteで8 byteの倍数にするには2 byte足りないため、それを調整した上で計算しています。

.git/refs, .git/HEAD

次最後に.git/refs, .git/HEADを見ていきましょう。これは今までのものとくらべて非常に楽です。なお、.git/refs配下にはheadsとtagsがありますが、今回はheadsのみ見ていきます。.git/refs/headsにはbranch名のファイルがそのまま置かれています。そして、その中身はCommitオブジェクトへの参照となっています。.git/HEADは現在のブランチへの参照が入っています。これらは単なるプレーンテキストですので単に下記のようになります。

import os


def ls_ref():
    ref_files = os.listdir(path='.git/refs/heads')
    for ref_file in ref_files:
        print(ref_file)


def cat_ref(name: str):
    with open('.git/refs/heads/' + name, 'r') as ref_file:
        print(ref_file.read())

def cat_head():
    with open('.git/HEAD', 'r') as head_file:
        print(head_file.read())

作ったプログラムを利用してGitの動作を追う

以上で作成したプログラムを利用してgitの動作を追っていきましょう。念の為全体のソースコードを下記においています。

https://gist.github.com/ikaro1192/933c380635220da7471b422ec1fb60a2

以下authorやcommitterは諸事情で伏せ字にしています。今わたしが使っているリポジトリを下記のように見てみます。

Python 3.8.5 (default, Jul 21 2020, 10:48:26)
[Clang 11.0.3 (clang-1103.0.32.62)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from ccsgit import *
>>> ls_ref()
test
master
>>> cat_head()
ref: refs/heads/master

testとmasterというrefが存在することがわかりました。 また現在のブランチはmasterです。 ということでmasterを内容を見てみましょう。

>>> cat_ref("master")
5a26fc77e7f35868c5db48ad493ab5fe59605a20

これは先ほど説明したように参照しているCommitオブジェクトのハッシュです。 ということでつぎはこの中身を見てみましょう。

>>> cat_obj("5a26fc77e7f35868c5db48ad493ab5fe59605a20")
type:commit
tree 20972f6e8cc05b659c81ab48ccaa2c0d089920ba
parent 24419734f0f9d1b2ac8e01685b35323da44315e6
author ******
committer ******

色々リファクタリング

コミットオブジェクトが表示されましたね。 これで参照しているTreeオブジェクトを覗いてみましょう。

>>> cat_obj("20972f6e8cc05b659c81ab48ccaa2c0d089920ba")
type:tree
100644 .gitignore c936e61de32da173b7df6221fe72a9525dcb1a2e
100644 ccsgit.py 6427d3b0874af8467aa6c2d74af5477810e5c529

.gitignoreとccsgit.pyというファイルを管理しているようです。 ccsgit.pyの中身を覗いてみましょう。

>>> cat_obj("6427d3b0874af8467aa6c2d74af5477810e5c529")
type:blob
import zlib
import os


def ls_obj():
(後略)

ということで表示できました。 つぎは.git/indexおよび.git/objectがどう変化するかを見ていきます。 最初の状況は下記です。

>>> cat_index()
version:2
---
0o100644 .gitignore c936e61de32da173b7df6221fe72a9525dcb1a2e
0o100644 ccsgit.py 6427d3b0874af8467aa6c2d74af5477810e5c529
>>> ls_obj()
6857e44f33ad9d9a1b5971aab5fc4afdc26aaebb
573085877a591c7adffd937ef83004757ad9b2c1
3d714ec8b91b36cf751362dc57094578cb8772f6
5a26fc77e7f35868c5db48ad493ab5fe59605a20
5f7229d0ceddcc977331db217f66dfcfed0da6ac
9daeafb9864cf43055ae93beb0afd6c7d144bfa4
ee22373afdf41480021030a7cce0b1b277cf23cb
c936e61de32da173b7df6221fe72a9525dcb1a2e
20972f6e8cc05b659c81ab48ccaa2c0d089920ba
6427d3b0874af8467aa6c2d74af5477810e5c529
cdb6165514fe83fc41ce1d3f63289aee774765a9
e6c4d2fa76fb4540abd7ead67884e1a0f79d6319
24419734f0f9d1b2ac8e01685b35323da44315e6

この状態から次のようなファイルを作ってみましょう。この操作は通常のターミナルから行います(以降は特に書きませんので文脈で察してください)。

$ vim hoge.txt
$ cat hoge.txt
hoge

この時点でcat_indexしてみると...

>>> cat_index()
version:2
---
0o100644 .gitignore c936e61de32da173b7df6221fe72a9525dcb1a2e
0o100644 ccsgit.py 6427d3b0874af8467aa6c2d74af5477810e5c529
>>> ls_obj()
6857e44f33ad9d9a1b5971aab5fc4afdc26aaebb
573085877a591c7adffd937ef83004757ad9b2c1
3d714ec8b91b36cf751362dc57094578cb8772f6
5a26fc77e7f35868c5db48ad493ab5fe59605a20
5f7229d0ceddcc977331db217f66dfcfed0da6ac
9daeafb9864cf43055ae93beb0afd6c7d144bfa4
ee22373afdf41480021030a7cce0b1b277cf23cb
c936e61de32da173b7df6221fe72a9525dcb1a2e
20972f6e8cc05b659c81ab48ccaa2c0d089920ba
6427d3b0874af8467aa6c2d74af5477810e5c529
cdb6165514fe83fc41ce1d3f63289aee774765a9
e6c4d2fa76fb4540abd7ead67884e1a0f79d6319
24419734f0f9d1b2ac8e01685b35323da44315e6

当然ながらaddしてないのでまだ変わってないですね。addしてみましょう。

git add hoge.txt
0o100644 .gitignore c936e61de32da173b7df6221fe72a9525dcb1a2e
0o100644 ccsgit.py 6427d3b0874af8467aa6c2d74af5477810e5c529
0o100644 hoge.txt 2262de0c121f22df8e78f5a37d6e114fd322c0b0
>>> ls_obj()
6857e44f33ad9d9a1b5971aab5fc4afdc26aaebb
573085877a591c7adffd937ef83004757ad9b2c1
3d714ec8b91b36cf751362dc57094578cb8772f6
5a26fc77e7f35868c5db48ad493ab5fe59605a20
5f7229d0ceddcc977331db217f66dfcfed0da6ac
9daeafb9864cf43055ae93beb0afd6c7d144bfa4
ee22373afdf41480021030a7cce0b1b277cf23cb
c936e61de32da173b7df6221fe72a9525dcb1a2e
20972f6e8cc05b659c81ab48ccaa2c0d089920ba
6427d3b0874af8467aa6c2d74af5477810e5c529
cdb6165514fe83fc41ce1d3f63289aee774765a9
e6c4d2fa76fb4540abd7ead67884e1a0f79d6319
24419734f0f9d1b2ac8e01685b35323da44315e6
2262de0c121f22df8e78f5a37d6e114fd322c0b0

と.git/indexにhoge.txtが追加され2262de0c121f22df8e78f5a37d6e114fd322c0b0というオブジェクトが生成されました。中身を確認してみると

>>> cat_obj("2262de0c121f22df8e78f5a37d6e114fd322c0b0")
type:blob
hoge

とたしかに追加したファイルの内容です。 次はcommitしてみましょう。

$ git commit -m 'hogeを追加してみた' 
>>> cat_index()
version:2
---
0o100644 .gitignore c936e61de32da173b7df6221fe72a9525dcb1a2e
0o100644 ccsgit.py 6427d3b0874af8467aa6c2d74af5477810e5c529
0o100644 hoge.txt 2262de0c121f22df8e78f5a37d6e114fd322c0b0
>>> ls_obj()
6857e44f33ad9d9a1b5971aab5fc4afdc26aaebb
573085877a591c7adffd937ef83004757ad9b2c1
3d714ec8b91b36cf751362dc57094578cb8772f6
5a26fc77e7f35868c5db48ad493ab5fe59605a20
5f7229d0ceddcc977331db217f66dfcfed0da6ac
9daeafb9864cf43055ae93beb0afd6c7d144bfa4
ee22373afdf41480021030a7cce0b1b277cf23cb
c936e61de32da173b7df6221fe72a9525dcb1a2e
20972f6e8cc05b659c81ab48ccaa2c0d089920ba
45f81de3b8e663788264c54dff4802c2609dfd5f
6e2245cfa722e995b72b48e7ec8def3c2f730a25
6427d3b0874af8467aa6c2d74af5477810e5c529
cdb6165514fe83fc41ce1d3f63289aee774765a9
e6c4d2fa76fb4540abd7ead67884e1a0f79d6319
24419734f0f9d1b2ac8e01685b35323da44315e6
2262de0c121f22df8e78f5a37d6e114fd322c0b0

とindexは変わっていませんが、45f81de3b8e663788264c54dff4802c2609dfd5fおよび6e2245cfa722e995b72b48e7ec8def3c2f730a25という新しいオブジェクトができていますね。これらの中身を確認してみると

>>> cat_obj("45f81de3b8e663788264c54dff4802c2609dfd5f")
type:commit
tree 6e2245cfa722e995b72b48e7ec8def3c2f730a25
parent 5a26fc77e7f35868c5db48ad493ab5fe59605a20
author ******
committer ******

hogeを追加してみた

>>> cat_obj("6e2245cfa722e995b72b48e7ec8def3c2f730a25")
type:tree
100644 .gitignore c936e61de32da173b7df6221fe72a9525dcb1a2e
100644 ccsgit.py 6427d3b0874af8467aa6c2d74af5477810e5c529
100644 hoge.txt 2262de0c121f22df8e78f5a37d6e114fd322c0b0

とCommitオブジェクトとTreeオブジェクトであることがわかりました。

以上でaddやcommitしたときの動作が見えてきたのではないでしょうか?

まとめ

今回はGitの内部構造を見るプログラムを実装しました。Gitリポジトリを操作し、作成したプログラムを利用することで各種操作とファイルの内容の対応をみることができました。これにより普段からGitで何を操作しているのかの理解が深まりました。なにをしているのかが見えてくるのでGitの操作でより応用が効くようになることが期待されます。今回実装していないものもまだまだありますので実装すると楽しいかもしれません。

明日の記事はとっちーのUnityの音の扱いに関する記事です。

yoooomaruuuu.hatenablog.com

参考資料

*1: Rob Pike: Notes on Programming in C

*2: 理論的にはSHA1は衝突することがあります。しかし、現実に衝突する確率は非常に低いです。

*3: \0はnull文字、0です。

*4: このzip圧縮しない状態のものをsha1ハッシュにかけたものがGitのSHA1ハッシュです。

塩水の濃度をずぼらに求める計算テクニック

はじめに

重量パーセント濃度が低い溶液を作りたい場合は重量パーセント濃度を溶媒と溶質の比としていいという話です。 計算間違ってる気がするのでコメントなりで気づいた人は指摘してください。

料理ではn %の食塩水等の溶液をつくるシーンがしばしば登場します。しかし、重量パーセント濃度は溶液と溶質の重量比として定義されておりいささか不便です。もしこれが重量パーセント濃度を溶媒と溶質の比としていいならば話は簡単になります。これは許されるのでしょうか?今回はこれについて検証します。

随分まえに書こう書こうとおもって放置していた記事です。 下書きにあったので供養します。

証明

溶質の質量をa,溶媒の質量をbとします。 このとき,重量パーセント濃度 100n は定義から

 n = \frac{a}{a+b}

と表せます。これをa/bについて解けば

 \frac{a}{b} = \frac{n}{1-n}

となります。右辺をnについてマクローリン展開すると

 \frac{a}{b} = 0+\frac{2n}{2 \cdot 1}+O(n^{2})

n<< 1の時,2次以上の項を無視して

 \frac{a}{b} =n

を得ます。

実用性について

溶質の質量を計測する場合誤差 \delta aが生じます。両方の方法で求めたaの値の差 \Delta aが誤差以下なら十分実用的と考えられます(溶媒を測る際の誤差は今回は無視します)。あるbのときにどういったn の値なら許されるかを考えてみましょう。

 \Delta aは下記のようにかけます。

\Delta a= \frac{n^{2}}{1-n}  b

したがって条件式は下記となります。

 \frac{n^{2}}{1-n}  b \leq  \delta a

これを 0 \leq nという条件に注意して解の公式をつかって解けば

 0 \leq n \leq \frac{-c+\sqrt{{\delta a}^2+4b \delta a}}{2b}

という条件式を得ます。ためしに  1.0 \times 10^{3} gの水(つまり1 Lの水)を使うことを考えてみましょう。また家庭用の料理スケールの誤差は2.0 g程度らしいです。そうするとnの範囲は

 0.0 \leq n \leq 4.3 \times 10^{-2}

と 4.3 %程度の値になります。よくつくる海水の塩分濃度が3.4 %程度ですから、まあまあ実用に耐えると考えることができます。

まとめ

毎回、面倒だなと思っていた溶液の濃度計算も海水の濃度程度ぐらいなら実用的に溶媒と溶質の比としていいという話でした。日常生活でつかうことがないと思われていたマクローリン展開や解の公式なんかも案外役に立つものですね。

CCS制作発表会オンライン〜冷蔵庫の中身等を管理するWebAppの途中経過〜

はじめに

巷ではCOVID-19で外出"自粛"がなされていますね。サークル活動ももちろん影響を受けています。そのため千葉大学電子計算機研究会(以下CCS)のオンライン制作発表会の中間発表が行われる下りになった模様(企画してくれたゆんらべくんに感謝)。ということで作りかけですが、作品の説明を出そうと思います。

さてCCSではWeb系のことをやってる人がほとんどいないです。もっとみんなやってほしい。ということでWebアプリを作って布教しようとおもいなにか作ってみることにしました。

時間がなかったので中間発表ということで今回はフロントエンド(ブラウザで表示される側)を実装していきたいと思います。フロントエンドの言語としては色々ありますが、今回はElmを採用しました。Elmは公式サイト(https://elm-lang.org)では「A delightful language for reliable webapps.(堅牢なウェブアプリケーションのための楽しい言語)」と書かれています。書いてみるとほんとにそのとおりでまったくストレスがありません。特徴としてはできるかぎり言語をシンプルに保とうとしていること、そのために関数型言語のアプローチを採用していることがあげられます。そして、エラーメッセージが非常に丁寧です。そして多くのフロントエンドの言語と同様にJavaScriptに最終的にはコンパイルされます。

なにを作るかということですが、日常的に困ってることを解決できるものができればいいなと思い考え始めました。困っていることといえば買い物と冷蔵庫の中身の管理。買い物は下記の図のように延々とTwitterの@のうしろが増え続けます(しかもかいもののときみわすれて残り続ける)。

f:id:ikaro1192:20200420013522p:plain

そして冷蔵庫の中身の管理はおなじものを何個も買ってしまったり、逆にあるとおもってなかったり...これらが確認できるアプリがあれば便利ですね。ということで冷蔵庫管理アプリを作っていきましょう!

実装とか

ソースコードは下記で公開しています。src/Main.elmがelmの本体です。

github.com

動作動画は下記です。

動画投稿してから削除機能がないことに気づいたけどもういいや...こんな感じで動的なWebアプリケーションをつくることが可能です。制作時間は6時間ぐらい?

ソースコードを解説していこうと思ったのですが、そんなことは色んな人がたくさん記事をかかれているのでElmつかってここまでで思ったことをかいていきます。わたしの解説を読むより公式ドキュメントを読んだほうが114514倍勉強になると思います。 guide.elm-lang.org

英語が苦手な方は有志が日本語翻訳してくれています。

guide.elm-lang.jp

まずはエラーメッセージが丁寧であるということです。たとえば selectedRefrigeratorNumをselected_refrigerator_numしてしまった場合下記のようなエラーメッセージが表示されました。

-- TYPE MISMATCH -------------------------------------------------- src/Main.elm

The `model` record does not have a `selected_refrigerator_num` field:

77|             { model | selected_refrigerator_num = String.toInt selectedRefrigerator |> Maybe.withDefault 0 }
                          ^^^^^^^^^^^^^^^^^^^^^^^^^
This is usually a typo. Here are the `model` fields that are most similar:

    { selectedRefrigeratorNum : Int
    , refrigerators : List Refrigerator
    , userName : String
    }

So maybe selected_refrigerator_num should be selectedRefrigeratorNum?

約すと「modelはselected_refrigerator_numをもってないよ。これは普通typoであることがおおい。にてるフィールドは下記の通り。だからもしかしてselected_refrigerator_numはselectedRefrigeratorNumじゃないかな?」というような感じですね。よむだけでtypoだとわかるだけでなくどうすればいいかまで教えてくれてありがたいですね。  もう一つの例を下記に示します。

-- TYPE MISMATCH -------------------------------------------------- src/Main.elm

I cannot update the `selected_refrigerator_num` field like this:

77|             { model | selected_refrigerator_num = String.toInt selectedRefrigerator }
                                                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This `toInt` call produces:

    Maybe.Maybe Int

But it should be:

    Int

Note: The record update syntax does not allow you to change the type of fields.
You can achieve that with record constructors or the record literal syntax.

Hint: Use Maybe.withDefault to handle possible errors. Longer term, it is
usually better to write out the full `case` though!

toIntはMaybe Intを返すけど、ここではIntが要求されているよという話ですね。Maybeは失敗するかもしれないことを示しています。 String.toIntはIntに変換する関数ですが、Intに変換できない可能性があるのでMaybe Intを返すのですね。他の言語だったらここで実行時に例外が発生したりnullが返ってきてプログラムがクラッシュするところですが、Elmの場合はそれをコンパイル時に防いでくれます。そしてどうすればいいかかかれているHintをよんでみると、「Maybe.withDefaultをつかえばエラーに対処できるけど、caseをつかって分岐したほうが長期的にはよりよい」といっていますね。ここでは考えてみた結果、デフォルト値をうまく設定できないと思ったので下記のようにcaseをつかったパターンマッチにして解決しました。

        AddFood food ->
            let
                getedValue =
                    MyUtil.getValue model.selectedRefrigeratorNum model.refrigerators
            in
            case getedValue of
                Nothing ->
                    model

                Just refrigerator ->
                    { model | refrigerators = MyUtil.changeValue model.selectedRefrigeratorNum { refrigerator | foods = food :: refrigerator.foods } model.refrigerators }

こんな感じで型をうまくつかうことで適切なエラーメッセージやプログラムの仕様の詰めが甘い部分を指摘してくれます。

あとは状態がModelに集中している、関数型言語の特徴で至るところで書き換わったりしないってのが嬉しいポイントかなーっておもいます。

だんだんねむくなってきた....

今後

現状永続化されてないのでバックエンドをどうにかしたいですね。こちらも勉強がてらGoLangで書きたいと思っていましたが、Pythonでかくようなきがします。あとはCSSをちゃんとつけてきれいにしていきたい。テストもちゃんとしたい...

まとめ

CCSのみんな軽率にWebアプリケーションつくってこうな!そしてフロントエンドやるときはElmつかうと楽しいよ!わたしも勉強中なので勉強会しましょう!(雑

お手軽スマートホームのすすめ

この記事はCCS †裏† Advent Calendar 2019の21日目の記事です。

adventar.org

前日のとしさんの記事はこちらです

docs.google.com

どうも千葉大電子計算機研究会(以下CCS)、老害㌠いかろちゃんです。はやいものでストレートでここまで来たはずなのにきづいたら9年も大学にいました。今年度で修了したいー!

はじめに

f:id:ikaro1192:20191220034413p:plain

ここ数年スマートホームが話題となっています。 ただ話題となるだけではなく最近はスマートスピーカーをはじめ様々なスマートホーム関連商品が一般向けにも多く発売されるようになりました。 その流れで比較的安価にスマートホームを実現する商品が多く出てきています。 特にスマート家電と銘打ったものではなく、今まであった家電をちょっとした工夫で(場合によっては工夫0で)スマート化できるという商品が現れ始めました。今回はそのような製品を使いお手軽にスマートホーム化する方法を紹介します。

実は私もスマートホーム化する前までは「ほんとに便利なの?」と思っていました。しかし、実際に使ってみると今はスマートホームなしでは生きていけない体になりました。まずうちでやっているスマートホームの構成と日々のルーチンを紹介をします。その後、スマートホーム化実装の一例としてGoogle HomeからのHDMI入力切り替えの実装を紹介します。これによってスマートホームの便利さや実は安価で簡単に実現できることを知ってもらえれば幸いです。 さあみなさんもじゃんじゃん家をスマートホーム化して幸せになりましょう!

ちなみに過去にもスマートホームで下記の記事を書いています。こちらでは今回紹介しないプログラミングをすることで便利になる一例を示しています。もしよかったらこちらもどうぞ。

hogespace.hatenablog.jp

構成

スマートホームを構成する要素は大きく分けて入力を担当する入力部、入力を受けて処理を行う処理部、処理の結果を使ってなにかを操作・出力する出力部、出力部によって制御される操作対象に別れます(ちなみにそれぞれ専門用語というわけではなくここで勝手に言っているだけです)。もちろん複数に属するものも存在します。入力部で入力をうけとり、処理部でなにかしら処理を行い、出力部で操作対象を操作します。入力、処理、出力とあるのはプログラムと一緒ですね。入力and/or出力が物理世界とリンクしているというのがスマートホームの特徴であり、通常のプログラムとの違いです。

わたしの家ではTVとChromecastが来る前までは下記構成で生活していました。

入力部として

  • Slack(サービス)
  • Backlog(サービス)
  • Google Home mini(ハードウェア)
  • Nature Remo(スマホアプリ)

処理部として

出力部として

  • Nature Remo mini(ハードウェア)
  • 照明リモコン化スイッチ(ハードウェア)
  • Google Home mini(ハードウェア)

操作対象として

  • エアコン(ハードウェア;当然!)
  • 電灯(ハードウェア;当然!)

があります。

次に以上の中でも肝になるスマートスピーカー(Google Home)、学習リモコン(Nature Remo mini)、そしてつなぎとしてつかっているIFTTTというサービスについて紹介します。

まずは色々なものごとの中心となるスマートスピーカーGoogle Homeについてです。スマートスピーカーは各社からGoogle Home以外にも例えばAlexaなど色々でてます。わたしはGoogleに情報収集され生活が便利にされてしまうのがすきなのでGoogle Homeにしました。スマートスピーカーは単純な受け答えはもちろん、他のスマート家電と連携することや制御が可能です。

Nature Remo (mini)とは学習リモコンと呼ばれるもので任意の赤外線リモコンを登録でき、そのリモコンとして振る舞えることができるようになるリモコンのことです。Nature Remoはスマホアプリ、Google Homeとの連携だけではなく、WebAPIも備えていますのでかなり自由度高く赤外線リモコンを操作できるようになります。ここらへんの細かい話は先程あげた記事を参照してください。Nature RemoにはNature RemoとNature Remo miniがあります。この2つの違いは値段と搭載しているセンサー、そして大きさの違いです。私の家ではNature Remo miniによって電灯とエアコンを制御しています。Nature Remo miniは7000円弱でかえる、たまにセールでさらに安く買えます。また、各種家電の赤外線化系のものは比較的安価に買える、そもそも赤外線リモコンがついていることが多いです。そのためNature Remoがあればわりと色々制御できるようになることが多いです。後半で紹介するHDMI切替器もNature Remoで制御します。

Nature スマートリモコン Nature Remo mini Remo-2W1

Nature スマートリモコン Nature Remo mini Remo-2W1

  • 出版社/メーカー: Nature 株式会社
  • メディア: エレクトロニクス

このあとに関係するものとしてIFTTTというサービスがあります。このサービスは複数のサービスをつなげることを目的とした無料のサービスでプログラミングをすることなくあるサービス(メール、Slack、 そしてもちろんGoogle Homeも!)でなにか特定のアクションがあったら別のサービスでなにかを実行させるというようなことができます。例えばTwitterで「さみしいにゃん」とつぶやいたらLINE botがどうしたの?と話しかけてくれるようにすることもできます。IFTTTでは一つ一つの「なにがあったら、なにをする」という塊をAppletと呼んでいます。このちいさな単位でやっていくため個々のAppletは簡単に、そして全体では色々できるということが実現できます。

具体的にスマートホームをどうつかっているのかわたしの一日(理想。現実よりだいぶ誇張してます)を見てみましょう。まず朝起きるとGoogle Homeからアラームが鳴り照明がつきます。アラームを声でとめたあと、「OK, google 音楽を流して」といい音楽を流しながら朝ごはんをたべます。食べ終わったら音楽をとめ、「OK goolge 今日の天気は?」と聞き天気と気温を把握し服をきます。そして、「OK Google いってきます」ということで家の照明とエアコンがoffになります。研究室に行き、「OK google...あっ...Google Homeないんだった」と恥ずかしい思いをした後、研究をし、家に帰ります。このときに日報をだしていればすでに電灯とエアコンがつきます。家に帰ったら「OK google ただいま」というとGoogle Homeが「おかえりなさい」といってくれるのでさみしいにゃんとなることもなくすみます。家に帰ってつかれたら「 OK, Google イッて」というとshimaidon.netで「んほぉぉ!イッぐぅぅ!!」とつぶやいてストレス解消ができます。これであれやこれやすませたあと、「OK google おやすみ」というと明日の予定、天気、そしてアラームの時間をセットしたあと、電気を消してくれます。もちろんそんなのめんどくさいー明日休みだー!ってふとんに倒れ込んでしまった日も「OK google、電気消して」といえば布団から一歩も出ずに電気が消えてくれます。 とまあ今感じでGoogleHomeに依存した生活を送っております。音声によって操作できることによって電灯とエアコンの操作をしなくてすむようになり格段にくらしやすくなりました。

スマートホーム化実装の一例

最近Google Chromecastとモニタを買いました。このモニタにはHDMI入力が1つしかありませんでした。しかし、ChromecastをつなぎたいときPCをつなぎたいときがあります。もちろんHDMI切替器を買えば解決します。ですがいちいち切り替え器を手で操作してって面倒です。リモコン付きHDMI切替器がありますが、リモコンってなくしがちです(わたしだけ?)。そこでGoogle Homeから声で操作できるようにしましょう。

Google Homeから直に制御できるHDMI切り替え装置はない(あっても多分高い)と考えられます。そこで赤外線リモコン付きHDMI切替器を学習リモコンを使って制御することを考えます。学習リモコンは家にあるNature Remo miniをそのまま流用します。またGoogle HomeとNature Remoの連携には今回はIFTTTを使います。

リモコン付きHDMI切替器は下記製品を使いました。

この製品は3口あること、ボタンがトグル式だけではなく、各ポートと番号が対応している点がよいです。というのも学習リモコンで制御するする場合はステートレスのほうが扱いやすい(操作の冪等性が保たれる;とりあえず実行しとけば望みの状態になる)からです。

以下の手順はめんどくさくなったのでUIがよく更新されるのであまり詳しく書きません。公式ドキュメントや他の記事を参考にしてください。 まずはHDMI切替器のリモコンをNature Remoに登録します。今回は1番ポートと2番ポートをそれぞれChromecastとPCに割り当てます。「+」アイコンを押すと新しい家電を追加する画面に推移します。今回は一般的器具ではないのでなんでもいいですがとりあえずAV機器にしました。Remoの指示通りに追加しましょう。

次にIFTTTの設定です。まずはIFTTTに登録しましょう。

ifttt.com

登録したら自分のAppletを追加します。this側はGoogleHome、then側はNature Remoにします。GoogleHomeは適当なキーワードで反応するようにして、Remo側は「Control home appliances」で先程登録したボタンを起動するようにしましょう。これを2回繰り返してPCとChromecastそれぞれに切り替えられるように登録します。 これで完成です。テストしてみましょう。

ちゃんとうごきましたね!これで好きなときに声で切り替えることができるようになりました。

このようにプログラミングができなくても結構色々カスタマイズできて楽しいです。もちろんプログラミングするとさらに幅が広がってもっと楽しいです。

まとめ

今回はスマートホームの概要を紹介しました。そして実際に使っている構成やルーチンを示すことでどのように生活に組み込むかを紹介しました。最後に新しく機器を追加する手順を簡単に紹介しました。今回示したようにNature Remoと既存の安価な赤外線リモコン、そしてIFTTTを組み合わせることで比較的安価・簡単に自宅をスマートホーム化することができます。またGoogle Home(Nust) miniは時々セールをやっていて0-3000円で手に入るイベントが多々あります。今もyoutubeプレミアム会員ならGoogle Nust miniが無料で手に入るキャンペーンをやっているようです。みなさんも高そう、難しそうと偏見を持たず安価にスマートホーム関連商品を手に入れて快適な生活を手に入れてみてはいかがでしょうか?

22日目は下記のとっちーの記事です。

yoooomaruuuu.hatenablog.com

なぜ弱火のレシピを時短のために強火にしてはいけないか1次元お肉を熱伝導方程式で焼いてみる

はじめに

料理の失敗談として「弱火で10分のところを強火ですれば時間短縮になる!」といって「外は黒焦げ、中が生焼け」となるという話をよく(?)聞きます。これはなぜでしょうか?今回はこれについて熱伝導方程式を解いてもとめようというのが今回の記事の趣旨です。 f:id:ikaro1192:20190603134316p:plain

理論と手法

物理において何が現象の主要な支配要因になってるかを把握するためにはパラメータを最小限に抑えて、現象をもっともらしく表現できる近似をしてあげることが大切です。今回は最低限、現象を説明できる条件の1次元の熱伝導を計算します。また、厳密に解けそうな気がしますが数値計算を用いて解いていきます。

ということで1次元のお肉を焼いていきましょう。1次元の熱伝導方程式は下記のように表現できます。

  \frac{\partial T}{\partial t} = \kappa  \frac{{\partial}^2 T}{{\partial} {x}^2}

ここでTは肉内部の温度分布で時間tと位置xの関数、κは熱拡散率です。肉の熱拡散率は(杉山,2013)*1にかかれている1.30×10^-7 m2/sを使います。境界条件としてフライパンに接している側は140度と200度(IHで弱火と強火でよく使われている値)とします。とりあえずあとで数式に登場させるためこれを T_hと置きます。また、空気と接している側の境界条件ですが断熱*2とします。これは料理をしている最中に肉と比べて部屋の温度がどんどん上がっていかないという物理的条件に対応します。また熱拡散率は本来温度に依存しますが簡単のため定数とします。焼くお肉の厚さですがここは庶民ですので L=1 cmのもの*3を用意します。そして、初期条件としてお肉の温度を一様に T_0=10度とします。 以上の条件を式にすると

  T(t,x=0) = T_h

   \frac{\partial T}{\partial x}  |_{x=L}= 0

  T(t=0,x \lt L) = T_0

次に数値計算できるよう離散化をします。今回は微分方程式の解き方としては陽解法を採用します。具体的には微分を下記の差分に置き換えます。

 \frac{\partial T}{\partial t} = \frac{T(t+\Delta t) - T(t)}{\Delta t}

 \frac{{\partial}^2 T}{\partial {x}^2} = \frac{T(x+\Delta x) - 2 T(x) + T(x-\Delta x)}{( {\Delta x}^2)}

ここで \Delta t \Delta xはそれぞれを表しています。

  \frac{T(t+\Delta t) - T(t)}{\Delta t} = \kappa  \frac{T(x+\Delta x) - 2 T(x) + T(x-\Delta x)}{( {\Delta x}^2)}

整理すれば

  T(t+\Delta t, x)  = T(t, x) + \frac{\kappa \Delta t}{{\Delta x}^2}  (T(t, x+\Delta x) - 2 T(t, x) + T(t, x-\Delta x))

を得ます。この調子で断熱の境界条件も離散化しましょう。

 \frac{\partial T}{\partial x} |_{x=L} = \frac{T(L) - T(L-\Delta x)}{\Delta x} =0

  T(L)  =T(L-\Delta x)

と最後とその手前の温度が同じという条件に帰着します。

以上によって求めた計算結果を1分、2分、3分での熱の分布を140度で加熱した場合、200度で加熱した場合で比較していきます。

実装

次に実装するだけです。今回はC++で実装しました。下にソースコードを示します。 また、いくつか式には出てこない部分があるので解説をしておきます。

#include<iostream>
#include<vector>

int main(){

    int input_temprature;
    int input_time;

    std::cin >> input_temprature >> input_time ;

    const double boundary_condition = input_temprature;//境界条件 [℃]
    const double temperature_init = 10; // 初期条件 [℃]
    const double thermal_diffusivity = 0.130; // [mm^2/s]
    const double x_max = 10; // お肉の厚さ [mm]
    const double dx = 0.1;
    const int Nx = x_max/dx;

    const double t_max = input_time;//加熱時間[s]
    const double dt = 0.8 * dx*dx/(2*thermal_diffusivity); //安定条件より。0.8は念の為。解説参照
    const int Nt = int(t_max/dt);

    std::vector<double> before_temperature;
    std::vector<double> now_temperature;

    //境界条件
    before_temperature.push_back(boundary_condition);

    //初期値を入れる
    for(int i =1; i<Nx;++i){
        before_temperature.push_back(temperature_init);
        now_temperature.push_back(temperature_init);
    }

    for(int j=0; j<Nt;++j){
        //境界条件
        now_temperature[0] = boundary_condition;
        for(int i =1; i < Nx-1; ++i){
            now_temperature[i] = before_temperature[i] + ((thermal_diffusivity* dt)/(dx*dx))*(before_temperature[i+1]-2*before_temperature[i]+before_temperature[i-1]);
        }

        //境界条件
        now_temperature[Nx-2] = now_temperature[Nx-3];
        before_temperature = now_temperature; // 本文参照
    }

    double x = 0;
    for(auto temperature : now_temperature){
        x+=dx;
        std::cout << x <<" "<< temperature << std::endl;
    }

    return 0;
}

まずは安定条件について。詳しくは数値計算の教科書等を参照してほしいのですが、今回採用した方法では \Delta t \Delta xが大きいと解が安定しません。具体的には

 \Delta t \leq \frac{{(\Delta x)}^2}{2\kappa}

を満たしている必要があります。そのためdtを下記式で求めています。

const double dt = 0.8 * dx*dx/(2*thermal_diffusivity); //安定条件より。0.8は念の為。解説参照

各経過時間を配列で確保することもできるのですが、それではメモリが多量に必要になります。そこで2ステップ分の配列を用意してそれを入れ替えていくことでメモリを節約します。下記部分です。

        before_temperature = now_temperature; // 本文参照

ほんとはこんなコピー発生させてコストがーとおもいますが、今回は計算を回してみてもそんなに時間がかからなかったのでこのままにしてあります。

境界条件は怪しいですのでもしバグを見つけたらおしえてください()

計算結果

記載の便宜上、「焼きすぎ」、「適温」、「生焼け」の3つを定義します*4。100度を超えた領域を「焼きすぎ」、50度を下回る領域を「生焼け」、そして100度以下、50度以上を「適温」とします。また適温部分の長さを「可食部」とすることにします。また、140度で3分やいたとき、200度で1分焼いたとき、200度で2分20秒焼いたときの温度分布をそれぞれパターンA、パターンB、パターンCとします。

計算結果のグラフは下記となります。

f:id:ikaro1192:20190611020718p:plain
フライパンからの距離と温度の関係。140度で3分やいたとき、200度で1分焼いたとき、200度で2分20秒焼いたときのそれぞれ温度分布を赤線、青線、黒線で示してある。破線は50度(生焼けの境界温度)と100度(焼きすぎの境界温度)を示している。

パターンAでは約2.9 mmにいったところで適温となっています。これには生焼け部はありませんので、したがってパターンAの可食部は7.1 mmとなります。パターンBは焼きすぎの領域がパターンAと一致しています。パターンBは5.0 mmのところで生焼けとなります。したがって、このときの可食部は2.1 mmとなります。残りは生焼けの部分で5.0 mmです。パターンCでは4.5 mmで適温となります。したがって可食部は5.5 mmとなります。表にまとめると下記のようになります。

パターン 焼きすぎ部の長さ(mm) 生焼け部の長さ(mm) 可食部部(mm)
A 2.9 0 7.1
B 2.9 5.0 2.1
C 4.5 0 5.5

パターンAとBを比べると2.9 mmを堺にしてプライパン側ではAよりBの温度が高くなり、外側ではAよりBの温度が低くなっています。パターンAとCを比べると常にCの温度がAの温度を上回っています。

考察

どう頑張っても140度の温度分布と200度の温度分布を一致させることはできませんでした。どんな時間加熱しても200度で加熱した場合は可食部は140度3分の場合の可食部以下となってしまいます。これは加熱する温度によって温度分布は異なるものになることを示します。温度分布が異なるのですから、弱火を強火で代用することができないことがわかります。

パターンAとBの比較からは表面の焼け具合を同様にすると中が生焼けになってしまうことがわかります。また、焼きすぎの部分でも200度のほうが同じ位置なら温度が高いです。これは「外は黒焦げ、中が生焼け」という状況に対応します。パターンCのグラフの意味することは外が黒焦げとなってしまった状況に対応します。つまり200度のほうがはやく表面は焼けますが、熱が中まではやく伝わっていくかは別問題ということを意味します。これは実は数値計算をするまでもなく数式でも理解することができます。離散化した式を見てみると次の瞬間の温度というのは周囲の温度差にある定数をかけたものとよむことができます。つまり、温度差があればそれだけ早く"平均的な"温度に落ち着こうとするものの、瞬時にその温度にいけるわけではないということを示しています。そういった時間ラグがあるため肉の中を一瞬で熱は伝わらず結局、表面には早く熱が伝わり焦げ、中の方は伝わらず生焼けとなってしまうのです。

まとめ

今回は1次元の豚肉を焼くとその温度分布はどうなるかを熱伝導方程式を数値計算を用いて解くことでもとめました。結果は加熱側の温度が違えば加熱時間をどう調整しようと同じ結果にすることはできないというものです。式を考察することにより熱は一瞬で伝わることができないため加熱温度が高ければ表面には早く熱が伝わり焦げ、中の方は伝わらず生焼けとなってしまうことを明らかにしました。ということでレシピの火加減と時間はまもるようにしましょう。

*1:https://www.jstage.jst.go.jp/article/cookeryscience/46/4/46_299/_pdf

*2:結論は大きく変わりませんが本当は少しづつ空気に熱は漏れ出すの熱抵抗を使ったほうがいいというコメント頂きました

*3:実は最初は3 cmでやったのですがうまく火が通らなかったのでこうしました

*4:実際に焦げたり美味しく安全に食べれる温度とは異なります

Nature RemoとGoogle Cloud Functionsをつかって日報を送ったら家の電気をつけるようにしてみた

はじめに

どうもこんにちはいかろちゃんです。最近自宅のスマートホーム化をはじめました。その第一弾として帰ることを検知して家の照明をつけようというのが今回の目的です。

個人用に研究室でBacklogをつかって日報という形で日々のまとめをその日の最後に書いています。また、家に帰ったら照明がついていてほしいですよね。日報が送って照明がつくようになればやる気も返った時のテンションも爆上がり間違いなしです。そこでBacklog→Google Cloud Functions(GCF)→Nature Remo→照明という流れで日報を送ったら家の照明をつける仕組みをつくってみました。

ハードウェアと使うサービスの概要

まずはNature Remoについて。これはいわゆる学習リモコンと呼ばれるもので、赤外線で動く電化製品のリモコンの信号を登録することによりそのリモコンとして振る舞えることができるようになるリモコンのことです。もちろん複数の電化製品の信号を学習させることも可能です。スマートフォンや、Google Home等のスマートスピーカーはもちろんリモコンとしてつかうことができます。そして一番嬉しい特徴がNature Remoはインターネット経由でアクセスできるWeb APIが公開されているという点です。このAPIをつかうことで任意のプログラムから赤外線信号を受け取る電化製品を操作することができるようになります。Nature RemoとNature Remo miniが存在しますが今回は安いRemo miniを利用しました(下記製品)。搭載しているセンサーと大きさ以外の機能は基本的に同一です。

Nature スマートリモコン Nature Remo mini Remo-2W1

Nature スマートリモコン Nature Remo mini Remo-2W1

  • 発売日: 2018/07/12
  • メディア: Tools & Hardware

次に照明のスマート化について。もともと自宅にあった照明はペンダント型照明(ようするに紐をひっぱるやつ)でした。紐を引くような機構をつくるのもありですが、よのなかには照明と電源の間に割って入って赤外線リモコンで操作可能にしてくれる製品も存在します。今回は「照明リモコンスイッチ OCR-CRS01W」を利用しました(下記製品)。この製品の嬉しいところは電灯に状態をもたせてボタンでトグルするのではなくonとoffボタンが存在することです。これにより面倒な状態管理をせずに操作できます。

次に日報管理です。これはBacklogを使っています。Backlogがどういうサービスかは今回とは関係ないので省きます。今回重要なのはコメントの追加をフックするWeb hockを登録できるという点です。これをNature Remoにつなげれば今回の目的は達成です。

Nature RemoのWeb APIはWeb HockではないのでBacklogと直接はつなげません。そこで今回はGoogle Cloud Functions(GCF)を仲介役としてはさみます。GCFはFunction as a Service(FaaS)の一つです。FaaSについての詳細な解説はここでははぶきますが、サーバレスであるイベントをうけてあるイベントを発火することが可能です。今回はBacklogからGCFを叩き、GCFがRemoのWeb APIを叩くという構成にします。

プログラムの流れ

Nature Remoをインターネット越しに操作するためにはアクセストークンを発行する必要があります。まずはhttps://home.nature.global/にアクセスしてログインします。その後、"Generate Access Token"でアクセストークンを発行しましょう。アクセストークンは後から確認できないようになっているのでメモを忘れずに。

Nature Remoに登録されている家電に対するそれぞれの操作はSignal IDとして管理されています。したがって、家電を操作するためにはSignal IDを特定する必要があります。下記コマンド(取得したアクセストークンの部分は適宜変更してください)で登録されている電化製品とその情報を得ることができます。nicknameを手がかりに対象の電化製品を特定しましょう。私の環境の場合は下記のような部分がありましたのでこれの電源onのSignal ID(dc1642ea-1d35-47bb-9ead-ab4aa8a51ac9")を使います。

curl -X GET "https://api.nature.global/1/appliances" -H "accept: application/json" -k --header "Authorization: Bearer 取得したアクセストークン"
(前略)
   "model": null,
    "type": "IR",
    "nickname": "照明",
    "image": "ico_light",
    "settings": null,
    "aircon": null,
    "signals": [
      {
        "id": "dc1642ea-1d35-47bb-9ead-ab4aa8a51ac9",
        "name": "電源on",
        "image": "ico_lightup"
      },
      {
        "id": "4ec42e50-b764-470a-963a-48a44e6ca364",
        "name": "消灯",
        "image": "ico_lightdown"
      }
(後略)

いよいよGCFをつかって連携部分を作っていきます。やることは単純で特定プロジェクトに投稿されたらNature RemoのAPIを叩くだけです。まずはGCFにログインし(この手順は省きます)、「関数を作成」をクリックします。関数名はdaily_report,メモリは128 MB,トリガーはHTTPとしました。表示されている「URL」という項目がBacklogに登録するWeb Hockとなります。ソースコードは下記のようにしました。下記のaccess_token, target_signal, project_keyを適切に設定すると動きます。project_keyはBacklogから調べてください。

import requests

access_token = '取得したアクセストークン'
target_signal = '取得したSignal ID'
project_key =  '日報投稿先のプロジェクトキー'

def daily_report(request):
    request_json = request.get_json()
    if  str(request_json["project"]["projectKey"]) == project_key:
        headers = {
            'accept': 'application/json',
            'Authorization': 'Bearer {}'.format(access_token),
        }
        response = requests.post('https://api.nature.global/1/signals/{}/send'.format(target_signal), headers=headers)


    return ""

最後にBacklogににWeb HockとしてGCPのURLを追加します。「プロジェクト設定」→「インテグレーション」→「Webhook」→「設定」をクリックしていきます。「WebHook URL」は先程GCFでえたURLとしましょう。また、通知するイベントは「課題にコメント」のみとしました。

これで完成です。投稿してみて実際に家の電気がつくか試して遊びましょう。

まとめと展望

今回はBacklogからRemoをGCFを使ってつなぐということを行いました。これによって日報を出し忘れると家が暗いままというかなしいことになり日報を出すモチベーションがアップです。今回はBacklogということでしたがWebhookに対応してれば同じパターンが使えますのでSlackやその他のものとも同様な手法でつなぐことができると思います。また、Remoは多様な電化製品に対応しており温度センサー等をそなえているので暑い時期ならついでにクーラーをつけるというようなことも可能です。いろいろアイディア次第で楽しいことができそうなのでたのしんでいきましょう!