isaakhanimann / psychonautwiki-journal-ios

GNU General Public License v3.0
35 stars 5 forks source link

[feature-request] Possesion Tracking #2

Closed keinsell closed 1 month ago

keinsell commented 1 year ago

As a User I would like to add substances that I own and use such data when creating ingestion. Possesion should have defined purity and amount - which will give user information which ingestions were truely same substance.

It's just draft of an idea, would be happy to disucss it more.

isaakhanimann commented 12 months ago

Great idea, and I'm looking into ways to implement this right now.

One part of it is that whenever you buy and test you create a new batch that has a purity, amount and maybe name and price. Then maybe on the choose dose screen there could be a picker for the batch with latest batch selected by default. Depending on this batch the purity would be prefilled and the name of the batch would be added to the ingestion.

A related idea would be to for each substance be able to store a dose preset. e.g. red pill = 12mg and blue pill = 20 mg and then on the choose dose screen you could additionally to directly defining the mg's just choose the number of pills e.g. 1.5 blue pills and it would store that info in the ingestion and also show that 1.5 blue pills is 30 mg. This could also be used for volumetric sprays or any other unit people want to use. E.g. if people want to track unknown doses they could just add e.g. 1 puff = unknown dose and then even though they didn't know how many mg one puff was they could still track the number of puffs.

What do you think?

keinsell commented 12 months ago

I was rather thinking about pure form like powder to be honest, it depends on approach - I would rather have stash in pure form of substance (like powder), and complex products on top of it may be a little struggle. There is a little model from one project on which I'm working which you may find helpful.

I think User should mark a existing ingestion as they are using something from stash, as this will allow for like recalculation of stash. Idea with presets is good, this can work aside of ingestion and stashes as it's probably just a link of preset as a form.

Sadly I cannot advise for custom stashes like puffs, this is a bit messy idea - you can always add custom units and operate on them but I'm not sure this will allow for precise (or any) analytical data :/

model Stash {
  id           String      @id @default(cuid())
  owner_id     String
  Subject      Subject?    @relation(fields: [owner_id], references: [id])
  substance_id String
  Substance    Substance   @relation(fields: [substance_id], references: [name])
  addedDate    DateTime?   @default(now())
  expiration   DateTime?
  amount       Int?
  unit         String?
  price        String?
  vendor       String?
  description  String?
  purity       Int?       
  ingestions   Ingestion[]
}

model Ingestion {
  id                    String     @id @default(cuid())
  substanceName         String?
  routeOfAdministration String?
  dosage_unit           String?
  dosage_amount         Int?
  isEstimatedDosage     Boolean?   @default(false)
  date                  DateTime?
  subject_id            String?
  Subject               Subject?   @relation(fields: [subject_id], references: [id])
  Substance             Substance? @relation(fields: [substanceName], references: [name])
  Stash                 Stash?     @relation(fields: [stashId], references: [id])
  stashId               String?
}
isaakhanimann commented 12 months ago

Yes, the idea of stashes would be just like in your model. I see that in your model you have a subject associated with the ingestion. That does add some complexity. Not sure if I'm up for that yet. So if you are only tracking your own use, calculating how much of the stash is left is much less useful. So I would probably show the actual amount that was bought more prominently.

I think your idea of having an explicit relation from ingestion to stash instead of an implicit one through a stash name is great.

The dose preset concept is not really related to the stash, its just for being able to use custom units for doses. One could also define the conversion from those units to the actual weight. I think that could make sense since people like to say that they have had "3 sprays", "1.5 pills", "2 tabs" and some people would also appreciate seeing those units in addition to the weight. I'm not a fan of unknown doses either but this feature could allow people to at least track it in terms of their own units.

Jordybeer commented 12 months ago

What about some kind of list where you can list substances you've tried like a true psychonaut? I keep a list on Things 3 including documentation based on personal experience.

keinsell commented 12 months ago

I see that in your model you have a subject associated with the ingestion.

Yeah, just ignore that part, when I was doing some coding stuff around psychonauts I was creating a "Subject" for every unique person from Erowid reports and like tracking all of the ingestions of specific user to later find potential correlations of effects but to be honest complexity and my lack of experience in data science a bit killed whole project.

I think your idea of having an explicit relation from ingestion to stash instead of an implicit one through a stash name is great.

Thanks that just enough to resole a problem of tracking stash and dynamically calculating reaming amount of given Substance. There's a one edge case of stash which I did not solved, it's a case where user forgot to note ingestions and actually consumed something from stash - the potential solution is to edit original amount - otherwise there should be additional entity related to stash that would work as "changelog" and actually changelog would define reaming amount in stash or maybe simpler but without tracking at all - field reamingAmount can be created and simplify modifying the state of stash everytime ingestion is edited.

About complexity, this can be done as simple feature right now like... really just Substance, Amount, Unit, ReamingAmount and synthetic connection to Ingestion (not even a relation in database but just plain editing of ReamingAmount - should work for users I think.

keinsell commented 12 months ago

What about some kind of list where you can list substances you've tried like a true psychonaut? I keep a list on Things 3 including documentation based on personal experience.

This is solely not topic of this issue, there are multiple ways to done such - for existing state of application you can just add past ingestion and ex. write your trip information, for future you can write feature request for something like "tag" next to substance name for things that you have done, however more information is needed from your side.

isaakhanimann commented 12 months ago

What about some kind of list where you can list substances you've tried like a true psychonaut? I keep a list on Things 3 including documentation based on personal experience.

You can already see that in the stats tab when you tap on e.g. the experience chart and set the time picker to "years".

isaakhanimann commented 12 months ago

Yeah, just ignore that part, when

Now that I'm thinking about it, its something I might add soon. I've had people ask me if they could also track ingestions for other people.

About complexity, this can be done as simple feature right now like... really just Substance, Amount, Unit, ReamingAmount and synthetic connection to Ingestion (not even a relation in database but just plain editing of ReamingAmount - should work for users I think.

The ReamingAmount is also a good idea. But it could just be a field of the stash directly right?

isaakhanimann commented 1 month ago

Possession tracking is a very big feature which would probably double the complexity of the app. Its not feasible for me to implement and maintain at the moment.

keinsell commented 1 month ago

I understand this, recently I was trying to explore some concepts around harm-reduction and rule-engine (ex. to say myself people to do not ingest stimulants when they are affecting one's sleep pattern) and the amount of calculations/parsing neccessary to made calculations possible at the first place is just wild. I will build PoC of such feature and then I may want come with PR but before I need to write one in my language to rewrite it in Kotlin as I do not know it well.

use std::fmt::{Debug, Display};
use std::time::Duration;

use chrono::{DateTime, Local, TimeZone};
use chrono_english::{Dialect, parse_date_string};
use chrono_humanize::HumanTime;
use log::{debug, error};
use measurements::Measurement;
use serde::{Deserialize, Serialize};
use termimad::MadSkin;
use crate::core::ingestion::{Ingestion, IngestionPhase, IngestionPhases};
use crate::core::mass::{deserialize_dosage, Mass};
use crate::core::phase::PhaseClassification;
use crate::core::route_of_administration::{
    get_dosage_classification_by_mass_and_route_of_administration,
    RouteOfAdministrationClassification,
};
use crate::core::dosage::{Dosage, DosageClassification};
use crate::core::substance::{
    get_phases_by_route_of_administration,
    get_route_of_administration_by_classification_and_substance,
};
use crate::service::ingestion::CreateIngestion;
use crate::service::substance::get_substance_by_name;

// https://docs.rs/indicatif/latest/indicatif/

#[derive(Debug, Clone, Serialize, Deserialize)]
struct DosageAnalysis {
    dosage_classification: DosageClassification,
}

#[derive(Debug)]
pub struct IngestionAnalysis {
    ingestion_id: i32,
    substance_name: String,
    route_of_administration_classification: RouteOfAdministrationClassification,
    dosage_classification: DosageClassification,
    dosage: Mass,
    pub phases: IngestionPhases,
    total_duration: Duration,
}

pub async fn analyze_future_ingestion(
    create_ingestion: &CreateIngestion,
) -> Result<IngestionAnalysis, &'static str> {
    let substance = get_substance_by_name(&create_ingestion.substance_name)
        .await
        .ok_or("Analysis failed: Substance not found")?;

    debug!("{:?}", substance);

    let route_of_administration = get_route_of_administration_by_classification_and_substance(
        &create_ingestion.route_of_administration,
        &substance,
    )
    .unwrap_or_else(|_| {
        error!("Analysis failed: Route of administration not found");
        panic!("Analysis failed: Route of administration not found");
    });

    // Parse mass from input
    let ingestion_mass = deserialize_dosage(&create_ingestion.dosage).unwrap_or_else(|_| {
        error!("Analysis failed: Invalid mass");
        panic!("Analysis failed: Invalid mass unit");
    });

    let dosage_classification = get_dosage_classification_by_mass_and_route_of_administration(
        &ingestion_mass,
        &route_of_administration,
    )
    .unwrap_or_else(|_| {
        error!("Analysis failed: Dosage classification not found");
        return DosageClassification::Unknown;
    });

    // Calculate ingestion plan based on phase information

    let phases = get_phases_by_route_of_administration(&route_of_administration);

    let total_duration = phases.iter().fold(Duration::default(), |acc, phase| {
        return if phase.phase_classification == PhaseClassification::Afterglow {
            acc
        } else {
            let added = acc + phase.duration_range.end;
            added
        }
    });

    let route_of_administration_phases = route_of_administration.phases.clone();

    let parsed_time = parse_date_string(&create_ingestion.ingested_at, Local::now(), Dialect::Us)
        .unwrap_or_else(|_| Local::now());

    let mut end_time = parsed_time;

    let phase_classifications = [
        PhaseClassification::Onset,
        PhaseClassification::Comeup,
        PhaseClassification::Peak,
        PhaseClassification::Offset,
        PhaseClassification::Afterglow,
    ];

    let ingestion_phases: IngestionPhases = phase_classifications.iter().filter_map(|classification| {
        route_of_administration_phases.get(&classification.clone()).map(|phase| {
            let ingestion_phase = IngestionPhase {
                phase_classification: classification.clone(),
                duration: phase.duration_range.clone(),
                start_time: end_time,
                end_time: end_time + phase.duration_range.end,
            };
            end_time = end_time + ingestion_phase.duration.end;
            (classification.clone(), ingestion_phase)
        })
    }).collect();

    let ingestion_analysis = IngestionAnalysis {
        ingestion_id: 0,
        substance_name: substance.name.clone(),
        dosage: ingestion_mass.clone(),
        route_of_administration_classification: route_of_administration.classification,
        dosage_classification,
        phases:ingestion_phases,
        total_duration,
    };

    pretty_print_ingestion_analysis(&ingestion_analysis);

    Ok(ingestion_analysis)
}

pub async fn analyze_ingestion(ingestion: &Ingestion) -> Result<IngestionAnalysis, &'static str> {
    let substance = get_substance_by_name(&ingestion.substance_name)
        .await
        .ok_or("Analysis failed: Substance not found")?;

    debug!("{:?}", substance);

    let route_of_administration = get_route_of_administration_by_classification_and_substance(
        &ingestion.administration_route,
        &substance,
    )
    .unwrap_or_else(|_| {
        error!("Analysis failed: Route of administration not found");
        panic!("Analysis failed: Route of administration not found");
    });

    let ingestion_mass = ingestion.dosage.clone();

    let dosage_classification = get_dosage_classification_by_mass_and_route_of_administration(
        &ingestion_mass,
        &route_of_administration,
    )
        .unwrap_or_else(|_| {
            error!("Analysis failed: Dosage classification not found");
            return DosageClassification::Unknown;
        });

    let phases = get_phases_by_route_of_administration(&route_of_administration);

    let total_duration = phases.iter().fold(Duration::default(), |acc, phase| {
        return if phase.phase_classification == PhaseClassification::Afterglow {
            acc
        } else {
            let added = acc + phase.duration_range.end;
            added
        }
    });

    let route_of_administration_phases = route_of_administration.phases.clone();

    let parsed_time = ingestion.ingested_at;
    let mut end_time = DateTime::<Local>::from(parsed_time.clone());

    let phase_classifications = [
        PhaseClassification::Onset,
        PhaseClassification::Comeup,
        PhaseClassification::Peak,
        PhaseClassification::Offset,
        PhaseClassification::Afterglow,
    ];

    let ingestion_phases: IngestionPhases = phase_classifications.iter().filter_map(|classification| {
        route_of_administration_phases.get(&classification.clone()).map(|phase| {
            let ingestion_phase = IngestionPhase {
                phase_classification: classification.clone(),
                duration: phase.duration_range.clone(),
                start_time: end_time,
                end_time: end_time + phase.duration_range.end,
            };
            end_time = end_time + ingestion_phase.duration.end;
            (classification.clone(), ingestion_phase)
        })
    }).collect();

    let ingestion_analysis = IngestionAnalysis {
        ingestion_id: ingestion.id.clone(),
        substance_name: substance.name.clone(),
        dosage: ingestion_mass,
        route_of_administration_classification: route_of_administration.classification,
        dosage_classification,
        phases: ingestion_phases,
        total_duration: Duration::from_secs(0),
    };

    Ok(ingestion_analysis)
}

pub fn pretty_print_ingestion_analysis(ingestion_analysis: &IngestionAnalysis) {
    let mut markdown = String::new();
    markdown.push_str(&format!(
        "{}", "-".repeat(40) + "\n"
    ));
    markdown.push_str(&format!(
        "Ingestion Analysis for **{}**\n",
        ingestion_analysis.substance_name
    ));
    markdown.push_str(&format!(
        "Route of Administration: **{:?}**\n",
        ingestion_analysis.route_of_administration_classification
    ));
    markdown.push_str(&format!(
        "Dosage: **{0:.0}**\n",
        ingestion_analysis.dosage
    ));
    markdown.push_str(&format!(
        "Dosage Classification: **{:?}**\n",
        ingestion_analysis.dosage_classification
    ));
    markdown.push_str(&format!(
        "Total Duration: **{:?}**\n",
        HumanTime::from(chrono::Duration::from_std(ingestion_analysis.total_duration).unwrap()).to_string()
    ));
    markdown.push_str(&"Phases:\n".to_string());

    let mut phases: Vec<(&PhaseClassification, &IngestionPhase)> = ingestion_analysis.phases.iter().collect();
    phases.sort_by_key(|&(classification, _)| *classification);

    for (phase_classification, phase) in phases {
        if phase.start_time < Local::now() {
            markdown.push_str(&format!(
                "   ▶ ~~{:?}: {:?}~~\n",
                phase_classification,
                HumanTime::from(phase.start_time).to_string()
            ));
        } else {
            markdown.push_str(&format!(
                "   ▶ **{:?}**: {:?}\n",
                phase_classification,
                HumanTime::from(phase.start_time).to_string()
            ));
        }
    }

    markdown.push_str(&format!(
        "{}", "-".repeat(40) + "\n"
    ));

    let skin = MadSkin::default();
    println!("{}", skin.term_text(&markdown));
}

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use crate::core::phase::PhaseClassification;
    use crate::core::route_of_administration::RouteOfAdministrationClassification;
    use crate::core::dosage::DosageClassification;
    use crate::ingestion_analyzer::analyze_future_ingestion;
    use crate::service::ingestion::CreateIngestion;

    #[tokio::test]
    async fn test_analyze_future_ingestion() {
        let create_ingestion = CreateIngestion {
            substance_name: String::from("caffeine"),
            route_of_administration: RouteOfAdministrationClassification::Oral,
            dosage: String::from("100 mg"),
            ingested_at: "now".parse().unwrap(),
        };

        let result = analyze_future_ingestion(&create_ingestion).await;

        match result {
            Ok(ingestion_analysis) => {
                assert_eq!(
                    ingestion_analysis.substance_name, "Caffeine",
                    "substance should be caffeine"
                );
                assert_eq!(
                    ingestion_analysis.route_of_administration_classification,
                    RouteOfAdministrationClassification::Oral,
                    "route of administration should be oral"
                );
                assert_eq!(
                    ingestion_analysis.dosage_classification,
                    DosageClassification::Common,
                    "dosage should be classified as common"
                );

                assert!(
                    ingestion_analysis.total_duration > Duration::from_mins(240),
                    "total duration should be greater than 4 hours"
                );

                assert!(
                    ingestion_analysis.total_duration < Duration::from_mins(300),
                    "total duration should be less than 5 hours"
                );

                // Assert that ingestion phases from the analysis contain onset phase.

                let ingestion_phases = ingestion_analysis.phases;

                assert!(
                    ingestion_phases
                        .iter()
                        .any(|phase| phase.0.clone() == PhaseClassification::Onset),
                    "Onset phase should be present in ingestion phases"
                );
            }
            Err(e) => panic!("Test failed: {}", e),
        }
    }
}

P.S. @isaakhanimann Can you mark issue as wontfix instead closed? Closed stands rather for implementation done or specific bug being fixed, where issues not really applicable to your application or maybe out of scope usually should be marked as wontfix.