Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@
package org.apache.texera.amber.operator.visualization.volcanoPlot

import com.fasterxml.jackson.annotation.{JsonProperty, JsonPropertyDescription}
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
import com.kjetland.jackson.jsonSchema.annotations.{JsonSchemaInject, JsonSchemaTitle}
import org.apache.texera.amber.core.tuple.{AttributeType, Schema}
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.PythonTemplateBuilderStringContext
import org.apache.texera.amber.pybuilder.PyStringTypes.EncodableString
import org.apache.texera.amber.pybuilder.PyStringTypes.{EncodableString, PythonLiteral}
import org.apache.texera.amber.core.workflow.PortIdentity
import org.apache.texera.amber.operator.PythonOperatorDescriptor
import org.apache.texera.amber.operator.visualization.PlotlyStandaloneCode
import org.apache.texera.amber.operator.metadata.annotations.{AutofillAttributeName, SampleColumn}
import org.apache.texera.amber.operator.metadata.{OperatorGroupConstants, OperatorInfo}
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder
import org.apache.texera.amber.pybuilder.PythonTemplateBuilder.pyStringLiteral

import javax.validation.constraints.NotNull
Expand Down Expand Up @@ -69,6 +71,34 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo
@NotNull(message = "P-Value Column cannot be empty")
var pvalueColumn: EncodableString = ""

// Numeric: both are compared against the data and the first is log10'd. contentAs
// names the boxed class — Option erases its element type, and a blank must not read as 0.
@JsonProperty(required = false)
@JsonSchemaTitle("Significance threshold")
@JsonPropertyDescription(
"P-value cut-off such as 0.05; draws a dashed horizontal line at -log10 of it"
)
@JsonSchemaInject(json = """{"exclusiveMinimum": 0, "maximum": 1}""")
@JsonDeserialize(contentAs = classOf[java.lang.Double])
var significanceThreshold: Option[Double] = None

@JsonProperty(required = false)
@JsonSchemaTitle("Effect-size threshold")
@JsonPropertyDescription(
"Effect-size cut-off such as 1; draws dashed vertical lines at plus and minus it"
)
@JsonSchemaInject(json = """{"exclusiveMinimum": 0}""")
@JsonDeserialize(contentAs = classOf[java.lang.Double])
var effectSizeThreshold: Option[Double] = None

@JsonProperty(required = false, defaultValue = "false")
@JsonSchemaTitle("Color by significance")
@JsonPropertyDescription(
"Color points Up, Down or Not significant by both thresholds instead of by effect size; " +
"needs both thresholds, otherwise the effect-size coloring is kept"
)
var colorBySignificance: Boolean = false

override def operatorInfo: OperatorInfo =
OperatorInfo.forVisualization(
userFriendlyName = "Volcano Plot",
Expand All @@ -84,6 +114,88 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo
Map(operatorInfo.outputPorts.head.id -> outputSchema)
}

/** A cut-off is used only when it can be drawn: finite, above zero and, for the
* p-value, at most one. Anything else counts as unset. The schema bounds keep such
* values out of the property panel; this keeps a hand-edited workflow from failing.
*/
private def isUsable(cutoff: Double): Boolean =
!cutoff.isNaN && !cutoff.isInfinite && cutoff > 0

private def significanceCutoff: Option[Double] =
significanceThreshold.filter(sig => isUsable(sig) && sig <= 1)

private def effectCutoff: Option[Double] = effectSizeThreshold.filter(isUsable)

/** Both cut-offs are needed to call a point Up or Down. */
private def colorByCategory: Boolean =
colorBySignificance && significanceCutoff.isDefined && effectCutoff.isDefined

/** Strict comparisons: a point exactly on a cut-off is not significant. */
private def significanceColumn: PythonTemplateBuilder =
if (colorByCategory) {
val sig = significanceCutoff.get
val eff = effectCutoff.get
pyb"""significant = table[$pvalueColumn] < $sig
| table["Significance"] = np.select(
| [significant & (table[$effectColumn] > $eff), significant & (table[$effectColumn] < -$eff)],
| ["Up", "Down"],
| default="Not significant",
| )
|"""
} else pyb""

// ColorBrewer RdBu end points plus a neutral grey, so the figure reads like other
// volcano-plot tooling; the continuous RdBu scale stays when not coloring by category.
// Shared by both code paths so the exported figure cannot drift from the engine's.
private def categoryColorArgs: PythonLiteral =
"""color="Significance",
| color_discrete_map={"Up": "#B2182B", "Down": "#2166AC", "Not significant": "#9E9E9E"},
| category_orders={"Significance": ["Up", "Down", "Not significant"]},""".stripMargin

private def colorArgs: PythonTemplateBuilder =
if (colorByCategory) pyb"$categoryColorArgs"
else
pyb"""color=$effectColumn,
| color_continuous_scale="RdBu","""

// The exported script's twins of the two helpers above: the same Python with the
// column names as literals rather than decoded, and the very same threshold literals.
private def standaloneSignificanceColumn: String =
if (colorByCategory) {
val sig = significanceCutoff.get
val eff = effectCutoff.get
val pvalueLit = pyStringLiteral(pvalueColumn)
val effectLit = pyStringLiteral(effectColumn)
s"""significant = table[$pvalueLit] < $sig
| table["Significance"] = np.select(
| [significant & (table[$effectLit] > $eff), significant & (table[$effectLit] < -$eff)],
| ["Up", "Down"],
| default="Not significant",
| )
|""".stripMargin
} else ""

private def standaloneColorArgs: String =
if (colorByCategory) categoryColorArgs
else
s"""color=${pyStringLiteral(effectColumn)},
| color_continuous_scale="RdBu",""".stripMargin

/** One dashed line per cut-off that is set: horizontal at -log10 of the p-value
* cut-off, vertical at plus and minus the effect-size cut-off.
*/
private def thresholdLines: PythonLiteral = {
val lines = scala.collection.mutable.ArrayBuffer[PythonTemplateBuilder]()
significanceCutoff.foreach { sig =>
lines += pyb"""fig.add_hline(y=-np.log10($sig), line_dash="dash", line_color="gray")"""
}
effectCutoff.foreach { eff =>
lines += pyb"""fig.add_vline(x=$eff, line_dash="dash", line_color="gray")"""
lines += pyb"""fig.add_vline(x=-$eff, line_dash="dash", line_color="gray")"""
}
lines.mkString("\n ")
}

override def generatePythonCode(): String = {
pyb"""
|from pytexera import *
Expand Down Expand Up @@ -113,16 +225,16 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo
| return
|
| table["-log10(pvalue)"] = -np.log10(table[$pvalueColumn])
|
| $significanceColumn
| fig = px.scatter(
| table,
| x=$effectColumn,
| y="-log10(pvalue)",
| hover_name=table.columns[0],
| color=$effectColumn,
| color_continuous_scale="RdBu",
| $colorArgs
| title="Volcano Plot"
| )
| $thresholdLines
|
| html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False)
| yield {"html-content": html}
Expand Down Expand Up @@ -152,15 +264,16 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo
| output.write(render_error("No rows with valid p-values."))
| else:
| table["-log10(pvalue)"] = -np.log10(table[$pvalueLit])
| $standaloneSignificanceColumn
| fig = px.scatter(
| table,
| x=$effectLit,
| y="-log10(pvalue)",
| hover_name=table.columns[0],
| color=$effectLit,
| color_continuous_scale="RdBu",
| $standaloneColorArgs
| title="Volcano Plot"
| )
| $thresholdLines
| fig.write_json(outputJson)
| fig.write_html(outputHtml)
| print("Volcano plot saved to " + outputHtml)""".stripMargin
Expand Down
Loading
Loading